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>
172 lines
8.2 KiB
JavaScript
172 lines
8.2 KiB
JavaScript
/**
|
||
* Copy and paste of scene objects and graph nodes (#240), with the real keys and the real menus.
|
||
* What only the running editor knows is where the pointer is: a paste under it has to land exactly
|
||
* there, and one without it has to land beside the original – the rules themselves are unit-tested.
|
||
* Everything pasted here is taken back by undo, so the fixture leaves as it came.
|
||
*/
|
||
export async function clipboard(t) {
|
||
const { check, json, evaluate, mouse, settle, press, click, clickText, select, showTab, undo, shot } = t;
|
||
|
||
const stored = () => json('__gm.state.activeScene.objects');
|
||
const selected = async () => (await json('__gm.state.selection')) ?? {};
|
||
const near = (a, b) => a && b && Math.abs(a.x - b.x) <= 1.5 && Math.abs(a.y - b.y) <= 1.5;
|
||
const moveTo = async (p) => {
|
||
await mouse('mouseMoved', p.x, p.y);
|
||
await settle();
|
||
};
|
||
|
||
await press('Escape');
|
||
await showTab('scene');
|
||
await evaluate('__gm.state.clipboard = null');
|
||
// Two crates of its own, each with a collider, where the seed has its red and blue crate. In the
|
||
// full run those two are long packed into a container by module 09, so they are no fixture here.
|
||
const BLUE = 'clip-blue';
|
||
const RED = 'clip-red';
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const put = (id, assetId, parentId, x, y, name) => {
|
||
const o = structuredClone(scene.objects[0]);
|
||
Object.assign(o, { id, assetId, parentId, name, position: { x, y, z: 0 }, overrides: {}, instanceEdits: {} });
|
||
delete o.groupId;
|
||
if ('enabled' in o) o.enabled = true;
|
||
scene.objects.push(o);
|
||
};
|
||
put(${JSON.stringify(BLUE)}, 'crate', '', 120, -80, 'Blue crate');
|
||
put(${JSON.stringify(BLUE)} + '-col', 'collider', ${JSON.stringify(BLUE)}, 0, 0, 'Collider');
|
||
put(${JSON.stringify(RED)}, 'crate', '', -150, 0, 'Red crate');
|
||
put(${JSON.stringify(RED)} + '-col', 'collider', ${JSON.stringify(RED)}, 0, 0, 'Collider');
|
||
__gm.state.select(null)`);
|
||
|
||
// 51a. Ctrl+C on the blue crate, Ctrl+V with the pointer over empty space: the crate and its
|
||
// collider come back as new objects, the crate standing right under the pointer.
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] });
|
||
await press('c', { ctrl: true });
|
||
const spot = await json('__gm.emptySpot()');
|
||
const before = await stored();
|
||
await moveTo(spot);
|
||
await press('v', { ctrl: true });
|
||
const afterPointer = await stored();
|
||
const pasted = (await selected()).objectId;
|
||
const copy = afterPointer.find((o) => o.id === pasted);
|
||
const copiedCollider = afterPointer.find((o) => o.parentId === pasted);
|
||
check(
|
||
'Ctrl+V puts a copy of the crate and its collider under the pointer',
|
||
afterPointer.length === before.length + 2 &&
|
||
copy?.assetId === 'crate' &&
|
||
copy.name === 'Blue crate' &&
|
||
copiedCollider?.assetId === 'collider' &&
|
||
near(await json(`__gm.objectScreen(${JSON.stringify(pasted)})`), spot),
|
||
JSON.stringify({ spot, copy, at: await json(`__gm.objectScreen(${JSON.stringify(pasted)})`) }),
|
||
);
|
||
check(
|
||
'the hierarchy lists the copy',
|
||
(await json('__gm.hierarchyOrder()')).includes(pasted),
|
||
JSON.stringify(await json('__gm.hierarchyOrder()')),
|
||
);
|
||
await shot('51-pasted-at-pointer');
|
||
await undo();
|
||
check('one undo takes the whole paste back', (await stored()).length === before.length);
|
||
|
||
// 51b. With the pointer over the hierarchy the copy has nowhere to be aimed: it lands one grid
|
||
// step down and right of the original, beside it rather than inside it.
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] });
|
||
await moveTo(await json(`__gm.hierarchyRow(${JSON.stringify(BLUE)})`));
|
||
await press('v', { ctrl: true });
|
||
const besideId = (await selected()).objectId;
|
||
const beside = (await stored()).find((o) => o.id === besideId);
|
||
const original = (await stored()).find((o) => o.id === BLUE);
|
||
check(
|
||
'without the pointer on the view the copy lands a grid step beside the original',
|
||
beside !== undefined &&
|
||
beside.id !== BLUE &&
|
||
beside.parentId === original.parentId &&
|
||
beside.position.x === original.position.x + 100 &&
|
||
beside.position.y === original.position.y + 100,
|
||
JSON.stringify({ original: original.position, beside }),
|
||
);
|
||
await undo();
|
||
|
||
// 51c. The object menu: right-click the red crate, "Paste" puts the copy inside what was hit –
|
||
// the crate or the collider drawn over it, whichever the pointer picks.
|
||
const red = await json(`__gm.objectScreen(${JSON.stringify(RED)})`);
|
||
await click(red.x, red.y, 'right');
|
||
const target = (await selected()).objectId;
|
||
const rows = (await json('__gm.menuRows()')) ?? [];
|
||
await clickText('Paste');
|
||
const insideId = (await selected()).objectId;
|
||
const inside = (await stored()).find((o) => o.id === insideId);
|
||
check(
|
||
'the object menu offers Copy and Paste, and Paste puts the copy into the object clicked',
|
||
rows.includes('Copy') &&
|
||
rows.includes('Paste') &&
|
||
[RED, `${RED}-col`].includes(target) &&
|
||
inside?.assetId === 'crate' &&
|
||
inside.parentId === target,
|
||
JSON.stringify({ target, rows, inside }),
|
||
);
|
||
await undo();
|
||
|
||
// 51f. The red crate drawn aside by its editor offset (#263): Ctrl+V with it selected puts the
|
||
// copy inside it and still right under the pointer, where the view shows the copy.
|
||
await t.mutate(`
|
||
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(RED)}).editorOffset = { x: 60, y: -40 }`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: RED, objectIds: [RED] });
|
||
const offsetSpot = await json('__gm.emptySpot()');
|
||
await moveTo(offsetSpot);
|
||
await press('v', { ctrl: true });
|
||
const underRedId = (await selected()).objectId;
|
||
const underRed = (await stored()).find((o) => o.id === underRedId);
|
||
const shownAt = await json(`__gm.objectScreen(${JSON.stringify(underRedId)})`);
|
||
check(
|
||
'Ctrl+V into a parent with an editor offset still lands under the pointer',
|
||
underRed?.assetId === 'crate' && underRed.parentId === RED && near(shownAt, offsetSpot),
|
||
JSON.stringify({ offsetSpot, shownAt, underRed: underRed && underRed.position }),
|
||
);
|
||
await undo();
|
||
check('the scene is back as it was', (await stored()).length === before.length);
|
||
|
||
// 51d. A node of the Mover behavior, copied and pasted under the pointer in its own graph.
|
||
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-axis', nodeIds: ['n-axis'] });
|
||
await showTab('graph');
|
||
await press('c', { ctrl: true });
|
||
const graphSpot = await json('__gm.graph.emptySpot()');
|
||
const nodes = () => json("__gm.state.project.assets.find((a) => a.id === 'mover').graph.nodes");
|
||
const count = (await nodes()).length;
|
||
await moveTo(graphSpot);
|
||
await press('v', { ctrl: true });
|
||
const newNode = (await selected()).nodeId;
|
||
const zoom = await json('__gm.graph.zoom()');
|
||
const title = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode)})`);
|
||
// `nodeAt` is the middle of the title bar; the node's corner is what lands on the pointer.
|
||
const corner = title && { x: title.x - (176 / 2) * zoom, y: title.y - (24 / 2) * zoom };
|
||
check(
|
||
'Ctrl+V in the graph puts a copy of the node under the pointer',
|
||
(await nodes()).length === count + 1 &&
|
||
(await nodes()).find((n) => n.id === newNode)?.type === 'AxisInput' &&
|
||
near(corner, graphSpot),
|
||
JSON.stringify({ graphSpot, corner, zoom }),
|
||
);
|
||
await undo();
|
||
|
||
// 51e. The palette on empty space offers Paste first, and it lands where the palette opened.
|
||
await click(graphSpot.x, graphSpot.y, 'right');
|
||
const offered = await json('__gm.graph.paletteRows()');
|
||
await clickText('Paste');
|
||
const fromPalette = (await selected()).nodeId;
|
||
check(
|
||
'the palette on empty space offers Paste, which adds the copy there',
|
||
offered[0]?.type === 'paste' &&
|
||
(await nodes()).length === count + 1 &&
|
||
(await nodes()).find((n) => n.id === fromPalette)?.type === 'AxisInput',
|
||
JSON.stringify(offered.slice(0, 3)),
|
||
);
|
||
await undo();
|
||
check('the graph is back as it was', (await nodes()).length === count);
|
||
await evaluate('__gm.state.clipboard = null');
|
||
await showTab('scene');
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('clip-'));`);
|
||
await t.saved();
|
||
}
|