Paste mixed two frames. The pointer comes from the Scene View, which draws every object moved by the editor offsets of it and its parents. Paste turned that point into a local position through the parents' plain world transform, so the copy landed beside the pointer by the offset of the parent it went into. Without a pointer, an original with an offset of its own had its copy placed beside its game position, which the view does not show. - A copied root now remembers where the view showed it (`ClipRoot.shown`, from `editorTransform`) instead of its world position. - New `editorLocalPositionFor` in `properties/transform.ts` turns a shown point into a local position through the parent chain with the editor offsets, as the view draws it. Paste uses it. - The copy itself still gets no editor offset of its own, as since #240. In the game it stands where it was drawn, minus the offsets of its parents. - Re-parenting under a parent with an offset still keeps the game position, so the object jumps in the view by that offset. That is a different rule from paste and is left as it is. Tests: - Unit (clipboard, groups, references, transform): 50 passed, 2 new. - e2e 51 on melektaus-atx: ALL PASSED, 12 checks, 1 new (51f). - Gate on melektaus-atx: 195 files, 2829 tests, CHECK EXIT 0. - Counter-checks: without the paste fix both new unit tests fail (copy at 540/470 instead of 500/500) and 51f fails by exactly the 60/-40 offset. With the root remembering its world position again, the own-offset unit test fails. - No full run. The e2e total of 1591 is 1590 plus 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
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,
|
||
SetEditorOffsetCommand,
|
||
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 { editorTransform, positionOf, worldPosition } from '../../src/core/properties/transform';
|
||
import { copySelection, pasteGraph, pasteObjects } from '../../src/editor/clipboard';
|
||
import { EditorState, nodeSelection, objectSelection } from '../../src/editor/state';
|
||
|
||
function run<C extends Command>(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 shown = (id: string) => positionOf(editorTransform(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, shown, 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('pastes under the pointer as the view shows it, the parents’ editor offsets included (#263)', () => {
|
||
const f = scene();
|
||
run(f.state, new SetEditorOffsetCommand(f.sceneId, f.a, { x: 40, y: -30 }));
|
||
const b = f.place({ x: 300, y: 100 });
|
||
f.copy([b]);
|
||
f.state.select(objectSelection(f.sceneId, [f.a]));
|
||
expect(pasteObjects(f.state, f.sceneId, { x: 500, y: 500 })).toBe(true);
|
||
const pasted = f.state.selectedObjectIds[0]!;
|
||
expect(findObject(f.level(), pasted)!.parentId).toBe(f.a);
|
||
expect(f.shown(pasted)).toMatchObject({ x: 500, y: 500 });
|
||
expect(f.world(pasted)).toMatchObject({ x: 460, y: 530 });
|
||
});
|
||
|
||
it('puts a copy a step beside where the view shows the original, its own offset included (#263)', () => {
|
||
const f = scene();
|
||
run(f.state, new SetEditorOffsetCommand(f.sceneId, f.a, { x: 40, y: -30 }));
|
||
run(f.state, new SetEditorOffsetCommand(f.sceneId, f.c, { x: 0, y: 25 }));
|
||
f.copy([f.c]);
|
||
expect(pasteObjects(f.state, f.sceneId, null)).toBe(true);
|
||
const pasted = findObject(f.level(), f.state.selectedObjectIds[0]!)!;
|
||
expect(pasted.parentId).toBe(f.a);
|
||
expect(pasted.editorOffset).toEqual({ x: 0, y: 0 });
|
||
expect(f.shown(pasted.id)).toMatchObject({ x: 250, y: 195 });
|
||
|
||
const b = f.place({ x: 300, y: 100 });
|
||
f.copy([b]);
|
||
f.state.select(objectSelection(f.sceneId, [f.a]));
|
||
expect(pasteObjects(f.state, f.sceneId, null)).toBe(true);
|
||
expect(f.shown(f.state.selectedObjectIds[0]!)).toMatchObject({ x: 400, y: 200 });
|
||
});
|
||
|
||
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 });
|
||
});
|
||
});
|