/** * 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(); }