Files
feuerwerk/tools/e2e/51-clipboard.mjs
T
adminandClaude Opus 5.5 38f009e035
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Successful in 10m55s
Make e2e 40, 46, 51 and 52 pass in the full run again
The suite job failed on every push since 6015869. Each module passed on
its own, but the full run showed two kinds of fault.

- e2e 40 still expected the object menu and the empty-cell menu from
  before #240 and #241. It now expects Copy, Paste, Group... and Remove
  from group, and Paste alone on an empty cell.
- e2e 46g read the outline of the first collider in the scene. In the
  full run that is a leftover of module 43, off screen, so it read as
  hidden before any switch. The check now places a collider of its own
  next to its tilemap and removes both at the end.
- e2e 51 copied the seed's red and blue crate. In the full run module 09
  has packed both into a container. The module now places two crates
  with colliders of its own and removes them at the end.
- e2e 52 compared whole row lists against the seed scene. In the full
  run that scene holds about 50 more rows. The module now works in a
  scene of its own, which it removes at the end. It had never run in CI
  because 51 died before it.

The check in e2e 41 that failed once (task 347, a dragged body 15 px
off in its first sample) is left alone. It failed in 1 of the last 60
logs, while a local e2e run loaded the same host.

Tests:
- full e2e run in the game-maker-ci image with GPU, as the runner does:
  ALL PASSED, 1452 checks, E2E EXIT 0 (the README sum is now measured)
- GM_STEPS=1,46,51,52 on the host: ALL PASSED, 51 checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:12:40 +02:00

154 lines
7.2 KiB
JavaScript
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.
/**
* 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();
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();
}