Files
feuerwerk/tests/editor/clipboard.test.ts
T
adminandClaude Opus 5.5 6d4247ebf8 Paste where the view shows it, editor offsets included (#263)
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>
2026-09-29 22:36:30 +02:00

287 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
});