import { describe, expect, it } from 'vitest'; import { AddAnnotationCommand } from '../../src/core/commands/annotationCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import type { Command } from '../../src/core/commands/Command'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { BEHAVIOR_PARAM, BEHAVIOR_START, RUN_BEHAVIOR, STATE } from '../../src/core/graph'; import type { BehaviorAsset, ContainerAsset, StateMachineAsset } from '../../src/core/model/Asset'; import { findObject } from '../../src/core/model/hierarchy'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import type { Vec2 } from '../../src/core/model/Properties'; import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; import { worldPosition } from '../../src/core/properties/transform'; import { copySelection, pasteGraph, pasteObjects } from '../../src/editor/clipboard'; import { EditorState, nodeSelection, objectSelection } from '../../src/editor/state'; function run(state: EditorState, command: C): C { // A rejected setup step only returns false and would let later checks pass for the wrong reason. expect(state.execute(command)).toBe(true); return command; } /** A scene with a sprite "Box" and a parent box `a` at (100, 100) holding `c` at world (110, 100). */ function scene() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const sceneId = run(state, new CreateAssetCommand('scene', 'Level')).asset.id; const spriteId = run(state, new CreateAssetCommand('sprite', 'Box')).asset.id; const place = (at: Vec2, parentId = '') => run(state, new PlaceAssetCommand(sceneId, spriteId, at, parentId)).objectId; const a = place({ x: 100, y: 100 }); const c = place({ x: 110, y: 100 }, a); const level = () => findScene(state.project, sceneId)!; const view = () => resolvedHolder(state.project, level()); const world = (id: string) => worldPosition(state.project, view(), findObject(view(), id)!); const copy = (ids: string[]) => { state.select(objectSelection(sceneId, ids)); expect(copySelection(state)).toBe(true); }; return { state, sceneId, spriteId, place, a, c, level, view, world, copy }; } describe('copy and paste of scene objects', () => { it('pastes the whole subtree with fresh ids, a grid step beside the original', () => { const f = scene(); f.copy([f.a]); f.state.select(null); expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); const [root] = f.state.selectedObjectIds; expect(root).toBeDefined(); expect(root).not.toBe(f.a); expect(f.world(root!)).toMatchObject({ x: 200, y: 200 }); const child = f.level().objects.find((o) => o.parentId === root)!; expect(child.id).not.toBe(f.c); expect(child.position).toEqual(findObject(f.level(), f.c)!.position); expect(f.level().objects).toHaveLength(4); f.state.undo(); expect(f.level().objects.map((o) => o.id)).toEqual([f.a, f.c]); }); it('centres the copied roots on the pointer', () => { const f = scene(); const b = f.place({ x: 300, y: 100 }); f.copy([f.a, b]); f.state.select(null); expect(pasteObjects(f.state, f.sceneId, { x: 500, y: 500 })).toBe(true); const [first, second] = f.state.selectedObjectIds; expect(f.world(first!)).toMatchObject({ x: 400, y: 500 }); expect(f.world(second!)).toMatchObject({ x: 600, y: 500 }); }); it('pastes into the one selected object and keeps the look under a turned parent', () => { const f = scene(); const target = f.place({ x: 0, y: 0 }); run(f.state, new SetOverrideCommand(f.sceneId, target, 'rotation', 90)); f.copy([f.c]); f.state.select(objectSelection(f.sceneId, [target])); expect(pasteObjects(f.state, f.sceneId, { x: 50, y: 0 })).toBe(true); const pasted = findObject(f.level(), f.state.selectedObjectIds[0]!)!; expect(pasted.parentId).toBe(target); expect(f.world(pasted.id).x).toBeCloseTo(50); expect(f.world(pasted.id).y).toBeCloseTo(0); expect(pasted.overrides.rotation).toBeCloseTo(-90); }); it('puts the copy beside a selected original or earlier paste, a step further each time', () => { const f = scene(); f.copy([f.c]); expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); const first = f.state.selectedObjectIds[0]!; expect(findObject(f.level(), first)!.parentId).toBe(f.a); expect(f.world(first)).toMatchObject({ x: 210, y: 200 }); expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); const second = f.state.selectedObjectIds[0]!; expect(findObject(f.level(), second)!.parentId).toBe(f.a); expect(f.world(second)).toMatchObject({ x: 310, y: 300 }); }); it('lets an undone paste give its spot back to the next one', () => { const f = scene(); f.copy([f.a]); pasteObjects(f.state, f.sceneId, null); f.state.undo(); expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); expect(f.world(f.state.selectedObjectIds[0]!)).toMatchObject({ x: 200, y: 200 }); }); it('keeps a copied container showing its template, and copies picked-out content as objects', () => { const f = scene(); const create = run(f.state, new CreateContainerCommand(f.sceneId, [f.a])); const containerId = create.objectId; const template = findAsset(f.state.project, create.assetId) as ContainerAsset; f.copy([containerId]); f.state.select(null); pasteObjects(f.state, f.sceneId, null); expect(f.level().objects).toHaveLength(2); const instance = f.state.selectedObjectIds[0]!; expect(findObject(f.level(), instance)!.assetId).toBe(create.assetId); expect(f.view().objects.filter((o) => o.id.startsWith(`${instance}~`))).toHaveLength(2); const content = instanceChildId(containerId, template.objects[0]!.id); f.copy([content]); f.state.select(null); pasteObjects(f.state, f.sceneId, null); const pasted = f.state.selectedObjectIds[0]!; expect(findObject(f.level(), pasted)!.assetId).toBe(f.spriteId); expect(f.level().objects.filter((o) => o.parentId === pasted)).toHaveLength(1); }); it('refuses a container pasted into its own template, and changes nothing', () => { const f = scene(); const create = run(f.state, new CreateContainerCommand(f.sceneId, [f.a])); f.copy([create.objectId]); f.state.select(null); const before = JSON.stringify(findAsset(f.state.project, create.assetId)); expect(pasteObjects(f.state, create.assetId, null)).toBe(false); expect(f.state.notice).toContain('nests forever'); expect(JSON.stringify(findAsset(f.state.project, create.assetId))).toBe(before); }); it('keeps a copied child with its original parent when it is pasted without a target', () => { const f = scene(); f.copy([f.c]); f.state.select(null); pasteObjects(f.state, f.sceneId, null); expect(findObject(f.level(), f.state.selectedObjectIds[0]!)!.parentId).toBe(f.a); }); it('forgets the clipboard with the project', () => { const f = scene(); f.copy([f.a]); expect(f.state.clipboard).not.toBeNull(); f.state.loadProject(createEmptyProject('q', 'Q'), 1); expect(f.state.clipboard).toBeNull(); }); }); /** A behavior holding OnUpdate → Log plus a lone Number, a comment, and a second, empty behavior. */ function graphs() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const behavior = run(state, new CreateAssetCommand('behavior', 'B')).asset as BehaviorAsset; const other = run(state, new CreateAssetCommand('behavior', 'Other')).asset as BehaviorAsset; const machine = run(state, new CreateAssetCommand('statemachine', 'M')).asset as StateMachineAsset; const add = (assetId: string, type: string, x: number, y: number, params = {}) => run(state, new AddNodeCommand(assetId, type, x, y, params)).node; const update = add(behavior.id, 'OnUpdate', 0, 0); const log = add(behavior.id, 'Log', 240, 0); const lone = add(behavior.id, 'Number', 0, 120); run( state, new ConnectCommand(behavior.id, { node: update.id, port: 'exec' }, { node: log.id, port: 'exec' }), ); run( state, new ConnectCommand(behavior.id, { node: lone.id, port: 'value' }, { node: log.id, port: 'message' }), ); const note = run( state, new AddAnnotationCommand(behavior.id, 'note', { x: 0, y: 300, w: 200, h: 96 }, 'Hi'), ); return { state, behavior, other, machine, add, update, log, lone, note: note.annotation }; } describe('copy and paste of graph nodes', () => { it('copies the nodes, the links between them and the boxes, a grid step beside the original', () => { const f = graphs(); f.state.select(nodeSelection(f.behavior.id, [f.update.id, f.log.id], undefined, [f.note.id])); expect(copySelection(f.state)).toBe(true); expect(pasteGraph(f.state, f.other.id, null, false)).toBe(true); const graph = f.other.graph; expect(graph.nodes.map((n) => [n.type, n.x, n.y])).toEqual([ ['OnUpdate', 24, 24], ['Log', 264, 24], ]); expect(graph.nodes.some((n) => n.id === f.update.id)).toBe(false); // The link from the lone Number stays behind with it. expect(graph.edges).toHaveLength(1); expect(graph.edges[0]!.from.node).toBe(graph.nodes[0]!.id); expect(graph.edges[0]!.to.node).toBe(graph.nodes[1]!.id); expect(graph.notes?.map((n) => [n.text, n.x, n.y])).toEqual([['Hi', 24, 324]]); expect(f.state.selectedNodeIds).toEqual(graph.nodes.map((n) => n.id)); expect(f.state.selectedAnnotationIds).toEqual([graph.notes![0]!.id]); f.state.undo(); expect(f.other.graph.nodes).toEqual([]); expect(f.other.graph.edges).toEqual([]); expect(f.other.graph.notes).toEqual([]); }); it('leaves out what the target graph cannot take and says so', () => { const f = graphs(); f.state.select(nodeSelection(f.behavior.id, [f.update.id, f.log.id])); copySelection(f.state); expect(pasteGraph(f.state, f.machine.id, null, false)).toBe(false); expect(f.state.notice).toContain('Nothing of the copy fits'); expect(f.machine.graph.nodes).toEqual([]); const state = f.add(f.machine.id, STATE, 0, 0); f.state.select(nodeSelection(f.machine.id, [state.id])); copySelection(f.state); expect(pasteGraph(f.state, f.behavior.id, null, false)).toBe(false); expect(f.machine.graph.nodes).toHaveLength(1); }); it('skips a second terminal and a behavior calling itself, pasting the rest', () => { const f = graphs(); const start = f.add(f.other.id, BEHAVIOR_START, 0, 0); const call = f.add(f.other.id, RUN_BEHAVIOR, 0, 100, { [BEHAVIOR_PARAM]: f.behavior.id }); const number = f.add(f.other.id, 'Number', 0, 200); f.add(f.behavior.id, BEHAVIOR_START, 0, 400); f.state.select(nodeSelection(f.other.id, [start.id, call.id, number.id])); copySelection(f.state); const before = f.behavior.graph.nodes.length; expect(pasteGraph(f.state, f.behavior.id, null, false)).toBe(true); expect(f.behavior.graph.nodes.slice(before).map((n) => n.type)).toEqual(['Number']); expect(f.state.notice).toContain('Start'); expect(f.state.notice).toContain('Run behavior'); }); it('puts a pointer paste on the grid when snapping is on', () => { const f = graphs(); f.state.select(nodeSelection(f.behavior.id, [f.lone.id])); copySelection(f.state); expect(pasteGraph(f.state, f.other.id, { x: 101, y: 50 }, true)).toBe(true); expect(f.other.graph.nodes.map((n) => [n.x, n.y])).toEqual([[96, 48]]); expect(pasteGraph(f.state, f.other.id, { x: 101, y: 50 }, false)).toBe(true); expect(f.other.graph.nodes[1]).toMatchObject({ x: 101, y: 50 }); }); });