Files
feuerwerk/tools/e2e/06-graph.mjs
T
adminandClaude Opus 5.5 b895ae99c0 Show the graph in the e2e 6 screenshot of the long name (#306)
The check for the long animation name ran after a play and stop, which
leave the editor on the scene tab. Its screenshot showed the scene, not
the node. The check itself read the row through the test hook and was
not affected.

- The step switches back to the graph tab before it renames the
  animation.

Tests:
- e2e 6: all passed, 149 checks. The screenshot shows the node, with
  "animation Swi…" ending before a whole "progress".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:37:08 +02:00

2219 lines
98 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.
/**
* The behavior graph editor: binding to the selection, dragging nodes, the palette (row click
* and Enter), connecting ports, deleting, the live highlight while playing, variables.
*/
export async function graph(t) {
const {
check,
sleep,
saved,
evaluate,
json,
find,
shot,
click,
clickText,
drag,
mouse,
locate,
mutate,
typeText,
press,
holdKey,
undo,
state,
select,
stop,
asset,
api,
navigate,
projectId,
showTab,
} = t;
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
const graphCounts = async () => {
const g = (await asset('mover')).graph;
return { nodes: g.nodes.length, edges: g.edges.length };
};
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
// 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it.
await clickText('Mover');
await showTab('graph');
check(
'graph editor shows the selected behavior',
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph',
);
await shot('16-graph');
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
const axisBefore = await nodeOf('n-axis');
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
// The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60.
const fitZoom = Number(await evaluate('__gm.graph.zoom()'));
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
let s = await state();
const axisAfter = await nodeOf('n-axis');
check(
'dragging a node selects and moves it',
s.selection?.kind === 'node' &&
s.selection.nodeId === 'n-axis' &&
Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 &&
Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2,
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
);
check(
'node inspector shows the node',
Boolean(await evaluate(`__gm.locate('Axis input')`)) &&
Boolean(await evaluate(`__gm.locate('DATA NODE')`)),
);
await undo();
const axisUndone = await nodeOf('n-axis');
check(
'undo moves the node back',
axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y,
`${axisUndone.x},${axisUndone.y}`,
);
// 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
// Bare canvas, searched rather than guessed: the console panel below shortens the canvas.
const bare = await json('__gm.graph.emptySpot()');
const beforeClickPick = await graphCounts();
await click(bare.x, bare.y, 'right');
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
const stopRow = await locate('Stop game'); // nothing else on screen shows this label
await click(stopRow.x, stopRow.y);
const afterClickPick = await graphCounts();
check(
'clicking a palette row adds exactly one node',
afterClickPick.nodes === beforeClickPick.nodes + 1,
JSON.stringify({ beforeClickPick, afterClickPick }),
);
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
await undo(); // so the next step finds the same empty spot and the expected graph
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(bare.x, bare.y, 'right');
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
// Unfiltered the palette holds far more nodes than fit, so it grows to the full height it may
// have – and that height is the canvas minus a tenth at each end. Shifting it up to fit used to
// put the entire gap above it and leave it flush on the bottom edge. Opened this far down it ends
// up with a tenth at both ends; the gap above is what is left over, not a rule of its own.
const paletteBox = await json('__gm.overlayRect()');
const paletteScreenH = await json('__gm.app.screen.height');
const paletteMargin = Math.round(paletteScreenH * 0.1);
const gapAbove = paletteBox.y;
const gapBelow = paletteScreenH - (paletteBox.y + paletteBox.h);
check(
'a palette shifted up to fit keeps the tenth of the canvas below it',
Math.abs(gapBelow - paletteMargin) <= 1 && gapAbove >= 0,
JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }),
);
await typeText('log');
await press('Enter');
const after13 = await graphCounts();
s = await state();
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check(
'palette adds a Log node and selects it',
after13.nodes === before13.nodes + 1 && newNode?.type === 'Log',
JSON.stringify({ after13, newNode }),
);
await shot('17-node-added');
// 6d2. The palette ranks by how well an entry answers the query (#43): a node *named* after it
// comes before one that merely mentions it in its description. "variable" used to hand
// Enter the first row alphabetically, which was `Run behavior`.
const beforeRank = await graphCounts();
// A fresh spot: the Log node from 6d now sits where `bare` pointed, and whether a click there
// hits it depends on how the layout snaps the node, not on anything this step tests.
const bareRank = await json('__gm.graph.emptySpot()');
await click(bareRank.x, bareRank.y, 'right');
await typeText('variable');
const ranked = await json('__gm.graph.paletteRows()');
const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable');
const lastNamed = ranked.map(hits).lastIndexOf(true);
const firstOther = ranked.map(hits).indexOf(false);
check(
'the palette lists the nodes named after the query first',
lastNamed >= 0 && (firstOther === -1 || lastNamed < firstOther) && hits(ranked[0]),
JSON.stringify(ranked),
);
await press('Enter');
s = await state();
const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check(
'Enter takes that node, not the one whose description mentions it',
rankPick !== null && rankPick.type.toLowerCase().includes('variable'),
JSON.stringify(rankPick),
);
await undo();
check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes);
// 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An
// exec output must not connect to a data input: dragging onto "message" does nothing.
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
const connected = await graphCounts();
check(
'dragging port to port connects the nodes',
connected.edges === before13.edges + 1,
JSON.stringify(connected),
);
const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`);
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
// Released on the port itself, which sits at the node's very edge: that is a drop on the node, not
// on empty canvas. An open palette here swallows every later click – it did, for four checks.
check(
'incompatible ports are rejected without taking the drop for empty canvas',
(await graphCounts()).edges === before13.edges + 1 &&
(await state()).mode === 'edit' &&
!(await evaluate(`__gm.locate('Search nodes…')`)),
);
await shot('18-connected');
// 13d2. A drop on the node's body instead of a port: it takes the first free compatible input,
// and the highlight names that port while the button is still down.
const axisX = await json(`__gm.graph.portAt('n-axis', 'x', true)`);
const logBody = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode.id)})`);
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
await mouse('mouseMoved', toPort.x, toPort.y, 'left', { buttons: 1 });
await sleep(50);
const overExec = await json(`__gm.graph.dropTarget()`);
check('a number drag over an exec port predicts nothing', overExec === null, JSON.stringify(overExec));
await mouse('mouseMoved', logBody.x, logBody.y, 'left', { buttons: 1 });
await sleep(50);
const overBody = await json(`__gm.graph.dropTarget()`);
check(
'over the node body it predicts the first free compatible input',
overBody?.nodeId === newNode.id && overBody.port === 'message' && overBody.isOutput === false,
JSON.stringify(overBody),
);
await shot('18b-drop-prediction');
await mouse('mouseReleased', logBody.x, logBody.y);
await sleep(150);
const dropped = await graphCounts();
const messageEdge = (await asset('mover')).graph.edges.find(
(e) => e.to.node === newNode.id && e.to.port === 'message',
);
check(
'the drop connects exactly the predicted port',
dropped.edges === before13.edges + 2 && messageEdge?.from.port === 'x',
JSON.stringify({ dropped, messageEdge }),
);
check('the prediction is dropped with the drag', (await json(`__gm.graph.dropTarget()`)) === null);
await undo();
check('undo removes that edge again', (await graphCounts()).edges === before13.edges + 1);
// 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach,
// and the picked node arrives already wired – as one undo step.
const empty = await json('__gm.graph.emptySpot()');
const beforeDrop = await graphCounts();
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
await sleep(50);
await mouse('mouseReleased', empty.x, empty.y);
await sleep(150);
check('a drop into empty space opens the palette', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
// "Restart game" takes exec only. Its label must not appear – and unlike "On update" it is not
// a node on the canvas, where `locate` would find it whatever the palette lists.
check(
'it lists a node that takes a number, not one that cannot',
Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)),
);
check(
'the dragged edge stays on screen while the palette decides',
(await json(`__gm.graph.danglingEdge()`)) === true,
);
await shot('18c-drop-into-empty');
const clampRow = await locate('Clamp');
await click(clampRow.x, clampRow.y);
const afterDrop = await graphCounts();
const wired = await state();
const clampEdge = (await asset('mover')).graph.edges.find(
(e) => e.from.node === 'n-axis' && e.from.port === 'x' && e.to.node === wired.selection?.nodeId,
);
check(
'the new node arrives connected to its first compatible input',
afterDrop.nodes === beforeDrop.nodes + 1 &&
afterDrop.edges === beforeDrop.edges + 1 &&
clampEdge?.to.port === 'value',
JSON.stringify({ afterDrop, clampEdge }),
);
check(
'and the dangling edge gives way to the real one',
(await json(`__gm.graph.danglingEdge()`)) === false,
);
await undo();
const undone = await graphCounts();
check(
'one undo takes back both the node and its edge',
undone.nodes === beforeDrop.nodes && undone.edges === beforeDrop.edges,
JSON.stringify(undone),
);
// Cancelling the palette has to take the dangling edge with it, or it would hang there forever.
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
await mouse('mouseReleased', empty.x, empty.y);
await sleep(150);
await press('Escape');
const cancelled = await graphCounts();
check(
'escaping the palette clears the edge and adds nothing',
(await json(`__gm.graph.danglingEdge()`)) === false &&
cancelled.nodes === beforeDrop.nodes &&
cancelled.edges === beforeDrop.edges,
JSON.stringify(cancelled),
);
// 6f. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
await press('Delete');
const deleted = await graphCounts();
check(
'Delete removes the node and its edge',
deleted.nodes === before13.nodes && deleted.edges === before13.edges,
JSON.stringify(deleted),
);
await undo();
const restored = await graphCounts();
check(
'undo restores node and edge',
restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1,
JSON.stringify(restored),
);
// 6g. The fps readout runs in both modes. Play always starts on the Game tab; switching to
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
check(
'the canvas bypasses the compositor queue by default',
(await evaluate('__gm.lowLatency()')) === true,
);
// Single evaluate: the readout refreshes twice a second, so read value and label together.
check(
'fps label is visible in the toolbar',
(await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true,
);
await t.play();
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
await sleep(600);
const fpsPlay = await evaluate('__gm.fps()');
check(
'fps readout keeps running in play mode',
typeof fpsPlay === 'number' && fpsPlay > 0,
`fps=${fpsPlay}`,
);
await showTab('graph');
const holding = holdKey('ArrowRight', 300);
await sleep(250);
await shot('19-graph-live');
await holding;
await stop();
// 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('+ Variable');
await clickText('Number');
check(
'adding a variable declares it on the behavior',
(await variableKeys()) === 'speed,var1',
await variableKeys(),
);
await shot('20-variable-added');
await undo();
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
// 6i. The tab keeps its subject: selecting something else leaves the graph alone, and the
// header picker switches behaviors without any selection at all.
const size = await graphCounts();
const readout = `${size.nodes} nodes · ${size.edges} links`;
check(
'the header reports the size of the shown graph',
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
readout,
);
check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…')));
await select(null);
check(
'graph editor keeps its behavior when nothing is selected',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
await select({ kind: 'asset', assetId: 'cam' });
check(
'graph editor keeps its behavior for a non-behavior asset',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
await select({ kind: 'asset', assetId: 'spawner' });
check(
'selecting another behavior leaves the tab on its own (#171)',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
await evaluate(`__gm.state.openAsset('spawner')`);
const twoOpen = await json('__gm.documents()');
check(
'opening it brings it up in a tab of its own',
(await evaluate(`__gm.graph.current()`)) === 'spawner' &&
twoOpen.active === 'asset:spawner' &&
twoOpen.docs.includes('asset:mover'),
JSON.stringify(twoOpen),
);
await evaluate(`__gm.closeDocument('asset:spawner')`);
check(
'closing it shows the behavior next to it again',
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await json('__gm.tab()')) === 'graph',
String(await evaluate(`__gm.graph.current()`)),
);
await select({ kind: 'asset', assetId: 'mover' });
// 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// under it included – and the left button no longer pans at all.
await clickText('Fit');
const positions = async () =>
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
const area = await json('__gm.graph.canvasRect()');
const before13i = await positions();
const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, {
button: 'middle',
});
const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`);
check(
'the middle button pans even when it starts on a node',
Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 &&
Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3,
JSON.stringify({ axisScreenBefore, axisScreenPanned }),
);
check(
'panning over a node leaves the node where it was',
(await positions()) === before13i,
await positions(),
);
// Fit again: the pan just now pushed a node out of view, and a band takes only what it sees.
await clickText('Fit');
// An empty spot: after a fit every node sits at least a margin away from the corner.
const corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(corner.x, corner.y, corner.x + 60, corner.y + 40);
const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`);
check(
'dragging with the left button no longer pans',
Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3,
JSON.stringify({ axisBeforeBand, axisAfterBand }),
);
// 6k. A rubber band across the whole canvas takes every node – the count comes from the model,
// so this cannot agree with the canvas by sharing its rule.
const nodeCount = (await asset('mover')).graph.nodes.length;
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const banded = await json('__gm.graph.selected()');
check(
'a rubber band selects every node it spans',
banded.length === nodeCount,
`${banded.length} of ${nodeCount}`,
);
await shot('21-graph-multiselect');
// 6l. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
// Screen pixels become graph units through the zoom, which the fit picks from the canvas size.
const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()'));
const [mx, my] = [await units(40), await units(30)];
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
const afterMove = await asset('mover');
const movedTogether = beforeMove.every((n) => {
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2;
});
check('dragging one of them moves the whole selection', movedTogether, await positions());
await undo();
check('one undo takes the group move back', (await positions()) === before13i, await positions());
// 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the
// current zoom – the canvas is not wide enough here for a zoom of 1.
const GRID = 24;
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
// The switch is a magnet (#194): a square icon button that stays lit while snapping is on.
const magnet = await json('__gm.graph.snapButton()');
await click(magnet.x, magnet.y);
check(
'the toggle switches it on without moving a single node',
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
await positions(),
);
const magnetOn = await json('__gm.graph.snapButton()');
check(
'the magnet is a square icon without a caption, and lit while on',
magnetOn.toggled && magnetOn.w === magnetOn.h && (await json(`__gm.locate('Snap')`)) === null,
JSON.stringify(magnetOn),
);
// A single node: dropped between two grid lines, it lands on the nearer one.
await click(corner.x, corner.y);
const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const snapBy = await units(50);
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
const snapped = await nodeOf('n-axis');
check(
'a dragged node lands on the grid, not where the pointer let go',
onGrid(snapped) &&
Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2,
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
);
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
check(
'the nodes it was dragged past stay off the grid',
others.some((n) => !onGrid(n)),
JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)),
);
// A group: every selected node snaps for itself, so the whole selection ends up on grid lines
// – off-grid gaps inside it close, rather than travelling along.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
check(
'the selection is not on the grid yet',
groupPre.some((n) => !onGrid(n)),
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
);
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const groupBy = await units(30);
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
const groupPost = (await asset('mover')).graph.nodes;
check(
'every selected node lines up on the grid for itself, none by more than half a step',
groupPost.every(onGrid) &&
groupPre.every((n) => {
const now = groupPost.find((m) => m.id === n.id);
return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2;
}),
await positions(),
);
await undo();
await undo();
check('undo takes both snapped moves back', (await positions()) === before13i, await positions());
// Switching it off has to give the pointer its exact drop position back.
await click(magnet.x, magnet.y);
await click(corner.x, corner.y);
const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const offBy = await units(50);
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
const offPost = await nodeOf('n-axis');
check(
'switched off, a node lands where it was dropped again, off the grid',
(await json('__gm.graph.snapping()')) === false &&
!onGrid(offPost) &&
Math.abs(offPost.x - (offPre.x + offBy)) <= 2 &&
Math.abs(offPost.y - (offPre.y + offBy)) <= 2,
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
);
await undo();
// 6m. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();
await press('Delete');
const afterDelete = await graphCounts();
check(
'Delete removes every selected node, edges with them',
afterDelete.nodes === 0 && afterDelete.edges === 0,
JSON.stringify(afterDelete),
);
await undo();
const restoredAll = await graphCounts();
check(
'one undo brings the whole graph back',
restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges,
JSON.stringify(restoredAll),
);
// 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
// and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
await click(axisRow.x, axisRow.y);
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis');
const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`);
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
check(
'ctrl-click adds a second node to the selection',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]),
JSON.stringify(await json('__gm.graph.selected()')),
);
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
check(
'ctrl-clicking it again removes it',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']),
JSON.stringify(await json('__gm.graph.selected()')),
);
// 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
// The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once
// the canvas is short, and then Fit cannot fit anything however correct it is.
const park = Math.round(area.w);
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`,
);
await sleep(250);
await clickText('Fit');
const rects = [];
for (const node of (await asset('mover')).graph.nodes)
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x));
check(
'the parked node makes the graph wider than the canvas, so fitting has to zoom out',
span > area.w * 0.5,
`span=${Math.round(span)} canvas=${Math.round(area.w)}`,
);
const outside = rects.filter(
(r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h,
);
check(
'Fit leaves no node hanging out of the view',
outside.length === 0,
`${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`,
);
await shot('22-graph-fitted');
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`,
);
await clickText('Fit');
check('the parked node is back where it started', (await positions()) === before13i, await positions());
// 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
// comes back while either of its two nodes is selected.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
const allEdges = (await asset('mover')).graph.edges;
const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0];
const id = JSON.stringify(edge.id);
const drawn = async () => await json(`__gm.graph.edgeDrawn(${id})`);
const edgeNow = async () => (await asset('mover')).graph.edges.find((e) => e.id === edge.id);
check('the link is drawn as a curve to start with', (await drawn()) === true);
const outPort = await json(
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(outPort.x, outPort.y, 'right');
check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
await clickText('Hide link');
await select({ kind: 'asset', assetId: 'mover' });
const hidden = await edgeNow();
check(
'hiding changes nothing but a flag – the connection is still there',
Boolean(hidden) &&
hidden.folded === true &&
hidden.from.node === edge.from.node &&
hidden.to.node === edge.to.node,
JSON.stringify(hidden),
);
check('and the curve is gone from the canvas', (await drawn()) === false);
const mark = await json(`__gm.graph.foldMark(${id}, true)`);
check(
'a stub with a mark takes its place at the port',
mark !== null && Math.abs(mark.x - outPort.x) < 40 && Math.abs(mark.y - outPort.y) < 8,
JSON.stringify({ mark, outPort }),
);
await shot('23-link-hidden');
// Selecting either end lends the curve back – the source first, then the target.
const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`);
await click(sourceAt.x, sourceAt.y);
check('selecting the node at one end brings the curve back', (await drawn()) === true);
const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`);
await click(targetAt.x, targetAt.y);
check('selecting the node at the other end does too', (await drawn()) === true);
await select({ kind: 'asset', assetId: 'mover' });
check('deselecting hides it again', (await drawn()) === false);
// The mark is the handle: clicking it selects the node at the far end, which is also what
// puts the curve back – so it answers "where does this go?" in one click.
const mark2 = await json(`__gm.graph.foldMark(${id}, true)`);
await click(mark2.x, mark2.y);
check(
'clicking the mark jumps to the node at the far end',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]),
JSON.stringify(await json('__gm.graph.selected()')),
);
check('which shows the curve again', (await drawn()) === true);
await undo();
await select({ kind: 'asset', assetId: 'mover' });
const shown = await edgeNow();
check(
'undo unhides the link, with nothing left behind',
shown.folded === undefined && (await drawn()) === true,
JSON.stringify(shown),
);
// 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
// the very same pixels – the names smearing over each other. One port, one mark.
const before13p = await graphCounts();
const forkPort = await json(
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
// A spot no node covers, measured rather than guessed: after the Fit above, where the nodes
// sit on screen is not something this step gets to assume.
const canvasArea = await json('__gm.graph.canvasRect()');
const taken = [];
for (const node of (await asset('mover')).graph.nodes)
taken.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
const freeSpots = [
{ x: canvasArea.x + 60, y: canvasArea.y + canvasArea.h - 60 },
{ x: canvasArea.x + canvasArea.w - 60, y: canvasArea.y + canvasArea.h - 60 },
{ x: canvasArea.x + 60, y: canvasArea.y + 60 },
];
const free =
freeSpots.find((p) =>
taken.every(
(r) => !r || p.x < r.x - 10 || p.y < r.y - 10 || p.x > r.x + r.w + 10 || p.y > r.y + r.h + 10,
),
) ?? freeSpots[0];
// The same gesture as 13d3: dropped into empty space the palette offers only what this port can
// reach, and the picked node arrives already wired – so no port name has to be guessed here.
await mouse('mouseMoved', forkPort.x, forkPort.y);
await mouse('mousePressed', forkPort.x, forkPort.y);
await mouse('mouseMoved', free.x, free.y, 'left', { buttons: 1 });
await sleep(50);
await mouse('mouseReleased', free.x, free.y);
await sleep(200);
const forkRow = await locate('Clamp');
if (forkRow) await click(forkRow.x, forkRow.y);
await sleep(250);
const twin = (await state()).selection?.nodeId;
const forked = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
);
check(
'a second link now leaves the same port',
forked.length === 2 && (await graphCounts()).nodes === before13p.nodes + 1,
JSON.stringify({ forked: forked.length, twin, free, row: Boolean(forkRow) }),
);
await click(forkPort.x, forkPort.y, 'right');
check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
await clickText('Hide 2 links');
await select({ kind: 'asset', assetId: 'mover' });
const ids = forked.map((e) => JSON.stringify(e.id));
const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`);
const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`);
check(
'both hidden links share one mark at the port instead of stacking two',
markA !== null && markA.x === markB.x && markA.y === markB.y && markA.links === 2,
JSON.stringify({ markA, markB }),
);
const marks = await json(`__gm.graph.foldMarks()`);
check(
'and only three marks are drawn for the two links: one shared, one at each far end',
marks.length === 3 && marks.filter((m) => m.links === 2).length === 1,
JSON.stringify(marks),
);
check(
'the shared mark names one far node and counts the rest',
markA.label.endsWith('+1') && markA.label.length > 2,
JSON.stringify(markA.label),
);
await shot('24-links-bundled');
// The bundled mark is still a handle: one click selects every far end, which is also what
// lends all of its curves back at once.
await click(markA.x, markA.y);
const picked = await json('__gm.graph.selected()');
check(
'clicking a bundled mark selects every node the port feeds',
picked.length === 2 && picked.includes(edge.to.node) && picked.includes(twin),
JSON.stringify(picked),
);
check(
'and both curves come back at once',
(await json(`__gm.graph.edgeDrawn(${ids[0]})`)) === true &&
(await json(`__gm.graph.edgeDrawn(${ids[1]})`)) === true,
);
await click(markA.x, markA.y, 'right');
check(
'the mark menu shows both links back in one go',
Boolean(await evaluate(`__gm.locate('Show 2 links')`)),
);
await clickText('Show 2 links');
await select({ kind: 'asset', assetId: 'mover' });
const unfolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
);
check(
'showing them again leaves no fold behind',
unfolded.every((e) => e.folded === undefined) && (await json(`__gm.graph.foldMarks()`)).length === 0,
JSON.stringify(unfolded),
);
// One step of undo takes both back – hiding two links was one command, so it is one undo.
await undo();
await select({ kind: 'asset', assetId: 'mover' });
const refolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
);
check(
'and undo re-hides both as one step',
refolded.length === 2 && refolded.every((e) => e.folded === true),
JSON.stringify(refolded.map((e) => e.folded)),
);
await undo();
const forkedNow = async () =>
(await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
).length;
const portNow = await json(
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(portNow.x, portNow.y, 'right');
const offered = await find('Disconnect 2 links');
if (offered) await click(offered.x, offered.y);
await sleep(250);
const cut = await forkedNow();
await undo();
check(
'disconnecting both links at a port is one undo step',
Boolean(offered) && cut === 0 && (await forkedNow()) === 2,
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
);
// 6r. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
const edgesBefore13q = (await graphCounts()).edges;
const openRect = await json(`__gm.graph.nodeRect('n-move')`);
const chevron = await json(`__gm.graph.chevronAt('n-move')`);
await click(chevron.x, chevron.y);
const shut = await json(`__gm.graph.nodeRect('n-move')`);
const zoom = openRect.w / 176;
check(
'the chevron collapses the node to its title bar, and leaves the selection alone',
(await nodeOf('n-move')).collapsed === true &&
Math.abs(shut.h - 24 * zoom) < 1 &&
(await json('__gm.graph.selected()')).length === 0,
JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }),
);
// Title starts 20 units in and ends 11 before the edge; `locate` matches exactly, so a cut title is null.
const titleText = await json(`__gm.locate('Move')`);
check(
'a collapsed node narrows to its whole title, keeping its left edge under the chevron',
titleText !== null &&
Math.abs(shut.x - openRect.x) < 1 &&
shut.w < openRect.w &&
Math.abs(shut.w - (2 * (titleText.x - shut.x) - 9 * zoom)) < 2,
JSON.stringify({ openRect, shut, titleText }),
);
const title = await json(`__gm.graph.nodeAt('n-move')`);
const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`);
const inputs = [
await anchorOf('exec', false),
await anchorOf('object', false),
await anchorOf('dy', false),
];
const output = await anchorOf('exec', true);
check(
'every link meets at one point per side, level with the title',
inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) &&
Math.abs(output.x - (shut.x + shut.w)) < 1 &&
Math.abs(output.y - title.y) < 1,
JSON.stringify({ inputs, output, title, shut }),
);
check(
'the links are still there and still drawn',
(await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true,
);
await shot('24b-node-collapsed');
const speedOut = await json(`__gm.graph.portAt('n-speed', 'speed', true)`);
await mouse('mouseMoved', speedOut.x, speedOut.y);
await mouse('mousePressed', speedOut.x, speedOut.y);
await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 });
await sleep(50);
const aimed = await json('__gm.graph.dropTarget()');
await mouse('mouseReleased', title.x, title.y);
await sleep(150);
await press('Escape');
check(
'a link dropped on a collapsed node goes nowhere',
aimed === null && (await graphCounts()).edges === edgesBefore13q,
JSON.stringify(aimed),
);
for (const port of ['x', 'y']) {
const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`);
await click(at.x, at.y, 'right');
await clickText('Hide link');
}
await select({ kind: 'asset', assetId: 'mover' });
const markDx = await json(`__gm.graph.foldMark('e3', false)`);
const markDy = await json(`__gm.graph.foldMark('e4', false)`);
check(
'hidden links into a collapsed node share one mark instead of stacking',
markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2,
JSON.stringify({ markDx, markDy }),
);
await undo();
await undo();
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
await click(title.x, title.y, 'right');
check(
'a mixed selection is offered both ways',
Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) &&
Boolean(await evaluate(`__gm.locate('Expand node', false)`)),
);
await press('Escape');
await undo();
check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined);
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
await click(openTitle.x, openTitle.y, 'right');
const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`);
check('two expanded nodes are offered as one', collapseBoth !== null);
if (collapseBoth) await click(collapseBoth.x, collapseBoth.y);
else await press('Escape');
await sleep(200);
const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed];
check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]');
await undo();
check(
'and one undo takes all of it back',
JSON.stringify(await both()) === '[null,null]',
JSON.stringify(await both()),
);
// Saved and loaded: a reload, not just the file, because the drawing is what has to come back.
await select({ kind: 'asset', assetId: 'mover' });
const again = await json(`__gm.graph.chevronAt('n-move')`);
await click(again.x, again.y);
const savedMove = async () => {
await saved();
const doc = await api(`projects/${projectId}`);
return doc.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move');
};
check('the collapse reaches the saved file', (await savedMove()).collapsed === true);
await navigate();
await sleep(500);
await select({ kind: 'asset', assetId: 'mover' });
await showTab('graph');
const reloaded = await json(`__gm.graph.nodeRect('n-move')`);
check(
'and a reloaded project draws it collapsed again',
(await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - shut.h / shut.w) < 0.01,
JSON.stringify(reloaded),
);
const reopen = await json(`__gm.graph.chevronAt('n-move')`);
await click(reopen.x, reopen.y);
check('expanding leaves no trace in the file', !('collapsed' in (await savedMove())));
// 6z. Ports carrying one value (#207): the output takes the type both inputs agree on, the
// inputs stay open for everything, and an edge the new type breaks is drawn as broken.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'picky',
type: 'behavior',
name: 'Picky',
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
{ id: 'p-a', type: 'Number', x: 0, y: 0, params: { value: 1 } },
{ id: 'p-b', type: 'Number', x: 0, y: 150, params: { value: 2 } },
{ id: 'p-text', type: 'String', x: 0, y: 300, params: { value: 'hi' } },
{ id: 'p-pick', type: 'FirstNotEmpty', x: 300, y: 60, params: {} },
{ id: 'p-sub', type: 'Substring', x: 600, y: 60, params: {} },
{ id: 'p-name', type: 'NameOf', x: 600, y: 300, params: {} },
],
edges: [],
},
});
__gm.state.select({ kind: 'asset', assetId: 'picky' });
`);
await showTab('graph');
const pickTypes = async () => json(`__gm.graph.portTypes('p-pick')`);
check(
'a group with nothing plugged in stays any',
(await pickTypes()).outputs.value === 'any',
JSON.stringify(await pickTypes()),
);
// An untouched input shows the value the node computes with: empty text reads "(empty)", a
// number 0, and an object with nothing plugged in "(null)".
const untouched = {
pick: await json(`__gm.graph.nodeInputs('p-pick')`),
sub: await json(`__gm.graph.nodeInputs('p-sub')`),
name: await json(`__gm.graph.nodeInputs('p-name')`),
};
check(
'untouched inputs show their default beside the name: (empty), 0 and (null) for an object',
JSON.stringify(untouched.pick) === JSON.stringify(['a (empty)', 'b (empty)']) &&
JSON.stringify(untouched.sub) === JSON.stringify(['text (empty)', 'start 0', 'count -1']) &&
JSON.stringify(untouched.name) === JSON.stringify(['object (null)']),
JSON.stringify(untouched),
);
const wire = async (fromNode, fromPort, toNode, toPort) => {
const from = await json(
`__gm.graph.portAt(${JSON.stringify(fromNode)}, ${JSON.stringify(fromPort)}, true)`,
);
const to = await json(`__gm.graph.portAt(${JSON.stringify(toNode)}, ${JSON.stringify(toPort)}, false)`);
await drag(from.x, from.y, to.x, to.y, 16);
};
// Laid while the output is still any, so a number later makes it a text input fed by a number.
await wire('p-pick', 'value', 'p-sub', 'text');
const toSub = async () => (await asset('picky')).graph.edges.find((e) => e.to.node === 'p-sub');
const outEdge = await toSub();
const edgeColor = async () => json(`__gm.graph.edgeColor(${JSON.stringify(outEdge?.id ?? '')})`);
const danger = await json(`__gm.themeColor('danger')`);
check(
'an any output connects to a text input and is drawn as a working link',
outEdge !== undefined && (await edgeColor()) !== danger,
JSON.stringify({ edge: Boolean(outEdge), color: await edgeColor(), danger }),
);
await wire('p-a', 'value', 'p-pick', 'a');
check(
'one input alone says nothing: the other could still become anything',
(await pickTypes()).outputs.value === 'any',
JSON.stringify(await pickTypes()),
);
await wire('p-b', 'value', 'p-pick', 'b');
const typed = await pickTypes();
check(
'with both inputs on a number the output is a number, the inputs stay any',
typed.outputs.value === 'number' && typed.inputs.a === 'any' && typed.inputs.b === 'any',
JSON.stringify(typed),
);
check(
'both input pins are painted as the number that flows in, though their type is any',
typed.paint.a === 'number' && typed.paint.b === 'number',
JSON.stringify(typed),
);
check(
'the edge the new type breaks stays and is drawn in the danger colour',
(await toSub()) !== undefined && (await edgeColor()) === danger,
JSON.stringify({ color: await edgeColor(), danger }),
);
await shot('19-shared-port-types');
// An input that keeps its `any` is one that can be re-plugged without unplugging first.
await wire('p-text', 'value', 'p-pick', 'a');
const mixed = await pickTypes();
check(
'a connected input takes another type, and the group opens up again',
mixed.outputs.value === 'any' && (await edgeColor()) !== danger,
JSON.stringify({ types: mixed, color: await edgeColor() }),
);
check(
'inputs fed by different types are painted apart, which is why the output says nothing',
mixed.paint.a === 'string' && mixed.paint.b === 'number' && mixed.inputs.a === 'any',
JSON.stringify(mixed),
);
// Read off the canvas: the colour is on the pin, not only in the definition.
const pinPixel = async (port) => {
const at = await json(`__gm.graph.portAt('p-pick', ${JSON.stringify(port)}, false)`);
return json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`);
};
const pinColors = { a: await pinPixel('a'), b: await pinPixel('b') };
const wanted = {
a: await json(`__gm.themeColor('portString')`),
b: await json(`__gm.themeColor('portNumber')`),
};
check(
'the two pins are drawn in those two colours',
pinColors.a === wanted.a && pinColors.b === wanted.b,
JSON.stringify({ pinColors, wanted }),
);
await shot('20-input-pin-colours');
// A second node of the kind behind the first (#210): the type is handed on through the chain,
// and taken back all along it once the front of the chain disagrees again.
await mutate(`
__gm.state.project.assets
.find((a) => a.id === 'picky')
.graph.nodes.push({ id: 'p-next', type: 'FirstNotEmpty', x: 300, y: 330, params: {} });
`);
const nextTypes = async () => json(`__gm.graph.portTypes('p-next')`);
await wire('p-pick', 'value', 'p-next', 'a');
await wire('p-pick', 'value', 'p-next', 'b');
const chainOpen = await nextTypes();
await wire('p-a', 'value', 'p-pick', 'a');
const chainTyped = await nextTypes();
check(
'a second node of the kind behind the first takes the type the first one agreed on',
chainOpen.outputs.value === 'any' &&
(await pickTypes()).outputs.value === 'number' &&
chainTyped.outputs.value === 'number' &&
chainTyped.inputs.a === 'any' &&
chainTyped.paint.a === 'number' &&
chainTyped.paint.b === 'number',
JSON.stringify({ chainOpen, chainTyped }),
);
await shot('20b-shared-type-chain');
await wire('p-text', 'value', 'p-pick', 'a');
const chainMixed = await nextTypes();
check(
'a text plugged into the first opens the whole chain up again',
(await pickTypes()).outputs.value === 'any' &&
chainMixed.outputs.value === 'any' &&
chainMixed.paint.a === 'any' &&
chainMixed.paint.b === 'any',
JSON.stringify(chainMixed),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'picky');
`);
// 6z-ii. An asset is a value (#237): Spawn keeps a pick saved as its old setting as the inline
// value of its new `asset` input, an Asset literal plugs into that input, and Child by
// name shows its two inputs.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'spawny',
type: 'behavior',
name: 'Spawny',
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
{ id: 'sp-spawn', type: 'Spawn', x: 300, y: 0, params: { asset: 'crate' } },
{ id: 'sp-lit', type: 'Asset', x: 0, y: 0, params: { value: 'crate' } },
{ id: 'sp-child', type: 'ChildByName', x: 300, y: 300, params: {} },
],
edges: [],
},
});
__gm.state.select({ kind: 'asset', assetId: 'spawny' });
`);
await showTab('graph');
const spawnRows = await json(`__gm.graph.nodeInputs('sp-spawn')`);
const childRows = await json(`__gm.graph.nodeInputs('sp-child')`);
check(
'Spawn shows the asset saved before as its input; Child by name shows object and name',
JSON.stringify(spawnRows) ===
JSON.stringify(['', 'asset Crate', 'x 0', 'y 0', 'z 0', 'parent (null)']) &&
JSON.stringify(childRows) === JSON.stringify(['object (null)', 'name (empty)']),
JSON.stringify({ spawnRows, childRows }),
);
await wire('sp-lit', 'value', 'sp-spawn', 'asset');
const assetEdge = (await asset('spawny')).graph.edges.find((e) => e.to.node === 'sp-spawn');
check(
'an Asset literal connects to the asset input of Spawn',
assetEdge?.from.node === 'sp-lit' && assetEdge.to.port === 'asset',
JSON.stringify(assetEdge ?? null),
);
await shot('21-asset-input');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'spawny');
`);
await lerpNode();
await forEachNode();
await findMatchNodes();
await splitOutputs();
await pluralLookups();
await eventOrder();
await animationPlaying();
await layerOf();
await pickedLinks();
await largeGraph();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
await showTab('scene');
// 6z-iii. Lerp (#246): typed into the palette it arrives linear with a 0…1 range, and the ease
// picked in the inspector lands on the node and on its setting row.
async function lerpNode() {
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'lerpy',
type: 'behavior',
name: 'Lerpy',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'lerpy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('lerp');
await press('Enter');
const lerp = (await asset('lerpy')).graph.nodes[0];
const inputs = lerp ? await json(`__gm.graph.nodeInputs(${JSON.stringify(lerp.id)})`) : null;
const settings = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null;
check(
'typing "lerp" into the palette adds a linear Lerp from 0 to 1',
lerp?.type === 'Lerp' &&
JSON.stringify(inputs) === JSON.stringify(['a 0', 'b 1', 't 0']) &&
JSON.stringify(settings) === JSON.stringify(['Ease: Linear']),
JSON.stringify({ lerp, inputs, settings }),
);
const easeField = await json(`__gm.inspectorField('Ease')`);
if (easeField) {
await click(easeField.x, easeField.y);
await clickText('Out');
}
const picked = lerp ? (await asset('lerpy')).graph.nodes.find((n) => n.id === lerp.id) : null;
const pickedRows = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null;
check(
'picking "Out" in the inspector sets the node and its setting row',
picked?.params.ease === 'out' && JSON.stringify(pickedRows) === JSON.stringify(['Ease: Out']),
JSON.stringify({ easeField, params: picked?.params, pickedRows }),
);
await shot('22-lerp');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'lerpy');
`);
}
// 6z-iv. For each (#249): typed into the palette it arrives with its ports, and a break built on
// the item finds the first loot named "Gold" – list.FirstOrDefault(x => x.Name == "Gold").
// The body logs the index of every round it runs, done logs index and stopped.
async function forEachNode() {
const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'eachy',
type: 'behavior',
name: 'Eachy',
props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} },
variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'eachy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('for each');
await press('Enter');
const each = (await asset('eachy')).graph.nodes[0];
const types = each ? await json(`__gm.graph.portTypes(${JSON.stringify(each.id)})`) : null;
check(
'typing "for each" into the palette adds For each with list and break in, item, index and stopped out',
each?.type === 'ForEach' &&
JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['exec', 'list', 'break']) &&
JSON.stringify(Object.keys(types?.outputs ?? {})) ===
JSON.stringify(['body', 'item', 'index', 'stopped', 'done']),
JSON.stringify({ each, types }),
);
if (!each) return;
const id = JSON.stringify(each.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'eachy');
const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('fe-start', 'OnStart', -300, 0),
node('fe-loots', 'Variables', -300, 160, {}, [{ key: 'loots', kind: 'list' }]),
node('fe-expand', 'ExpandStruct', -300, 300, {}, [{ key: 'Name', kind: 'text' }]),
node('fe-equals', 'Equals', -80, 300, { b: 'Gold' }),
node('fe-body', 'Log', 400, 0),
node('fe-done', 'Log', 400, 200),
node('fe-join', 'Join', 200, 260),
);
behavior.graph.edges.push(
edge('fe-e1', 'fe-start', 'exec', ${id}, 'exec'),
edge('fe-e2', 'fe-loots', 'loots', ${id}, 'list'),
edge('fe-e3', ${id}, 'item', 'fe-expand', 'value'),
edge('fe-e4', 'fe-expand', 'Name', 'fe-equals', 'a'),
edge('fe-e5', 'fe-equals', 'result', ${id}, 'break'),
edge('fe-e6', ${id}, 'body', 'fe-body', 'exec'),
edge('fe-e7', ${id}, 'index', 'fe-body', 'message'),
edge('fe-e8', ${id}, 'done', 'fe-done', 'exec'),
edge('fe-e9', ${id}, 'index', 'fe-join', 'a'),
edge('fe-e10', ${id}, 'stopped', 'fe-join', 'b'),
edge('fe-e11', 'fe-join', 'text', 'fe-done', 'message'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'fe-holder', assetId: 'eachy', parentId: '', name: 'Each holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
`);
await shot('23-for-each');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Each holder]'))
.map((line) => line.split(']').pop().trim());
check(
'the loop stops at the first "Gold": the body ran for Axe only, done sees index 1 and stopped',
JSON.stringify(logged) === JSON.stringify(['0', '1true']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'eachy');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'fe-holder');
`);
}
// 6z-v. Find … Match: typed into the palette, Find arrives with its Match beside it. The test in
// between is dragged into the Match with the mouse, which closes the block and frames it.
// Run, it finds the first loot named "Gold" and goes on at found with that one.
async function findMatchNodes() {
const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'findy',
type: 'behavior',
name: 'Findy',
props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} },
variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'findy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('find');
await press('Enter');
const [find, match] = (await asset('findy')).graph.nodes;
const findTypes = find ? await json(`__gm.graph.portTypes(${JSON.stringify(find.id)})`) : null;
const matchTypes = match ? await json(`__gm.graph.portTypes(${JSON.stringify(match.id)})`) : null;
const lonely = find ? await json(`__gm.graph.nodeProblem(${JSON.stringify(find.id)})`) : null;
check(
'typing "find" into the palette adds Find with its Match beside it, both ports as planned',
find?.type === 'Find' &&
match?.type === 'Match' &&
match.x > find.x &&
match.y === find.y &&
JSON.stringify(Object.keys(findTypes?.outputs ?? {})) === JSON.stringify(['item', 'index']) &&
JSON.stringify(Object.keys(matchTypes?.inputs ?? {})) === JSON.stringify(['match']) &&
JSON.stringify(Object.keys(matchTypes?.outputs ?? {})) ===
JSON.stringify(['found', 'item', 'index', 'notFound']) &&
lonely === 'No Match tests its item',
JSON.stringify({ find, match, findTypes, matchTypes, lonely }),
);
if (!find || !match) return;
const f = JSON.stringify(find.id);
const m = JSON.stringify(match.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'findy');
const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
const at = behavior.graph.nodes.find((n) => n.id === ${f});
behavior.graph.nodes.push(
node('fm-start', 'OnStart', at.x - 240, at.y - 60),
node('fm-loots', 'Variables', at.x - 240, at.y + 80, {}, [{ key: 'loots', kind: 'list' }]),
node('fm-expand', 'ExpandStruct', at.x + 200, at.y + 90, {}, [{ key: 'Name', kind: 'text' }]),
node('fm-equals', 'Equals', at.x + 200, at.y + 200, { b: 'Gold' }),
node('fm-found', 'Log', at.x + 700, at.y - 60),
node('fm-name', 'ExpandStruct', at.x + 700, at.y + 120, {}, [{ key: 'Name', kind: 'text' }]),
node('fm-join', 'Join', at.x + 900, at.y + 120),
);
behavior.graph.edges.push(
edge('fm-e1', 'fm-start', 'exec', ${f}, 'exec'),
edge('fm-e2', 'fm-loots', 'loots', ${f}, 'list'),
edge('fm-e3', ${f}, 'item', 'fm-expand', 'value'),
edge('fm-e4', 'fm-expand', 'Name', 'fm-equals', 'a'),
edge('fm-e5', ${m}, 'found', 'fm-found', 'exec'),
edge('fm-e6', ${m}, 'item', 'fm-name', 'value'),
edge('fm-e9', 'fm-name', 'Name', 'fm-join', 'a'),
edge('fm-e7', ${m}, 'index', 'fm-join', 'b'),
edge('fm-e8', 'fm-join', 'text', 'fm-found', 'message'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'fm-holder', assetId: 'findy', parentId: '', name: 'Find holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
`);
// With the console open the graph is short: fit it, or the ports lie under the console.
await clickText('Fit');
const framesBefore = await json('__gm.graph.blockFrames()');
const from = await json(`__gm.graph.portAt('fm-equals', 'result', true)`);
const to = await json(`__gm.graph.portAt(${m}, 'match', false)`);
if (from && to) await drag(from.x, from.y, to.x, to.y, 16);
const wired = (await asset('findy')).graph;
const frames = await json('__gm.graph.blockFrames()');
const inside = (id) => {
const n = wired.nodes.find((node) => node.id === id);
const b = frames[0]?.bounds;
return !!n && !!b && n.x > b.minX && n.x < b.maxX && n.y > b.minY && n.y < b.maxY;
};
const problems = [await json(`__gm.graph.nodeProblem(${f})`), await json(`__gm.graph.nodeProblem(${m})`)];
check(
'dragging the test into match closes the block: one frame around Find, Expand, Equals and Match only',
framesBefore.length === 0 &&
wired.edges.some(
(e) => e.from.node === 'fm-equals' && e.to.node === match.id && e.to.port === 'match',
) &&
frames.length === 1 &&
[find.id, 'fm-expand', 'fm-equals', match.id].every(inside) &&
!['fm-start', 'fm-loots', 'fm-found', 'fm-name', 'fm-join'].some(inside) &&
problems.every((problem) => problem === null),
JSON.stringify({ from, to, framesBefore, frames, problems }),
);
await shot('24-find-match');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Find holder]'))
.map((line) => line.split(']').pop().trim());
check(
'run, the search goes on at found with the first "Gold", index 1',
JSON.stringify(logged) === JSON.stringify(['Gold1']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'findy');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder');
`);
}
// 6z-vi. Split outputs: the item of a Find over a typed list is split with a right-click on its
// pin, straight into the fields of the list's type. A field port is dragged into the test,
// the Match's item is split as well, and a run logs the amount of the loot found. A right-
// click on a field port puts the output together again.
async function splitOutputs() {
const loots = [
{ Name: 'Axe', Amount: 1 },
{ Name: 'Gold', Amount: 7 },
];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'sp-loot',
type: 'struct',
name: 'Loot 6',
props: { Name: '', Amount: 0 },
variables: [
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
{ key: 'Amount', label: 'Amount', kind: 'number', default: 0 },
],
});
p.assets.push({
id: 'splity',
type: 'behavior',
name: 'Splity',
props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} },
variables: [
{ key: 'loots', label: 'loots', kind: 'list', itemType: 'sp-loot', default: ${JSON.stringify(loots)} },
],
graph: { nodes: [], edges: [] },
});
const behavior = p.assets.find((a) => a.id === 'splity');
const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('sp-start', 'OnStart', -240, -60),
node('sp-loots', 'Variables', -240, 80, {}, [{ key: 'loots', kind: 'list' }]),
node('sp-find', 'Find', 0, 0),
node('sp-equals', 'Equals', 240, 120, { b: 'Gold' }),
node('sp-match', 'Match', 480, 0),
node('sp-log', 'Log', 760, 0),
);
behavior.graph.edges.push(
edge('sp-e1', 'sp-start', 'exec', 'sp-find', 'exec'),
edge('sp-e2', 'sp-loots', 'loots', 'sp-find', 'list'),
edge('sp-e3', 'sp-equals', 'result', 'sp-match', 'match'),
edge('sp-e4', 'sp-match', 'found', 'sp-log', 'exec'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'sp-holder', assetId: 'splity', parentId: '', name: 'Split holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
__gm.state.select({ kind: 'asset', assetId: 'splity' });
`);
await showTab('graph');
await clickText('Fit');
const outputsOf = async (id) =>
Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {});
const splitPin = async (id, port, entry) => {
const pin = await json(`__gm.graph.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, true)`);
if (!pin) return false;
await click(pin.x, pin.y, 'right');
await sleep(150);
await clickText(entry);
await sleep(150);
return true;
};
const opened = await splitPin('sp-find', 'item', 'Split into Loot 6 fields');
const types = await json(`__gm.graph.portTypes('sp-find')`);
check(
'right-click on the item of a Find over a typed list splits it into the fields of that type',
opened &&
JSON.stringify(Object.keys(types?.outputs ?? {})) ===
JSON.stringify(['item.Name', 'item.Amount', 'index']) &&
types.outputs['item.Name'] === 'string' &&
types.outputs['item.Amount'] === 'number',
JSON.stringify({ opened, types }),
);
await clickText('Fit');
const from = await json(`__gm.graph.portAt('sp-find', 'item.Name', true)`);
const to = await json(`__gm.graph.portAt('sp-equals', 'a', false)`);
if (from && to) await drag(from.x, from.y, to.x, to.y, 16);
await splitPin('sp-match', 'item', 'Split into Loot 6 fields');
await clickText('Fit');
const amount = await json(`__gm.graph.portAt('sp-match', 'item.Amount', true)`);
const message = await json(`__gm.graph.portAt('sp-log', 'message', false)`);
if (amount && message) await drag(amount.x, amount.y, message.x, message.y, 16);
const wired = (await asset('splity')).graph.edges;
await shot('25-split-outputs');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Split holder]'))
.map((line) => line.split(']').pop().trim());
check(
'field ports dragged into the test and the log: a run logs the Amount of the loot named "Gold"',
wired.some((e) => e.from.port === 'item.Name' && e.to.node === 'sp-equals') &&
wired.some((e) => e.from.port === 'item.Amount' && e.to.node === 'sp-log') &&
JSON.stringify(logged) === JSON.stringify(['7']),
JSON.stringify({ from, to, amount, message, logged }),
);
// Play shows the Game tab; the pin is only there to be clicked with the graph in front again.
await showTab('graph');
await clickText('Fit');
await splitPin('sp-find', 'item.Name', 'Recombine item');
const back = await outputsOf('sp-find');
const left = (await asset('splity')).graph.edges;
check(
'right-click on a field port recombines the output, the edge on the field goes with it',
JSON.stringify(back) === JSON.stringify(['item', 'index']) &&
!left.some((e) => e.from.node === 'sp-find' && e.from.port.startsWith('item.')),
JSON.stringify({ back, left }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'splity' && a.id !== 'sp-loot');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder');
`);
}
// 6z-vii. Plural lookups and Find modes (#259): "filter" in the palette adds a Find set to All,
// whose Match hands on lists. Count picked in the inspector reshapes the Match and drops
// the edge on a list output it no longer has. A run over a small tree answers every new
// node: Objects by name, Children by name, Children (direct and recursive), Objects of
// asset, and a Filter over the children.
async function pluralLookups() {
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'plury',
type: 'behavior',
name: 'Plury',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'plury' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('filter');
await press('Enter');
const [find, match] = (await asset('plury')).graph.nodes;
const outputsOf = async (id) =>
Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {});
const settings = find ? await json(`__gm.graph.nodeSettings(${JSON.stringify(find.id)})`) : null;
if (!find || !match) {
check(
'typing "filter" into the palette adds a Find and its Match',
false,
JSON.stringify({ find, match }),
);
return;
}
const f = JSON.stringify(find.id);
const m = JSON.stringify(match.id);
// Only a Match whose test is built from the Find follows its mode: the test is wired first.
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'plury');
const at = behavior.graph.nodes.find((n) => n.id === ${m});
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
{ id: 'pl-test', type: 'Not', x: at.x - 240, y: at.y + 120, params: {} },
{ id: 'pl-count', type: 'Count', x: at.x + 260, y: at.y + 60, params: {} },
);
behavior.graph.edges.push(
edge('pl-in', ${f}, 'item', 'pl-test', 'value'),
edge('pl-out', 'pl-test', 'result', ${m}, 'match'),
edge('pl-items', ${m}, 'items', 'pl-count', 'list'),
);
`);
const listOutputs = await outputsOf(match.id);
check(
'typing "filter" into the palette adds a Find set to All; its Match hands on items and indices',
find.type === 'Find' &&
find.params.mode === 'all' &&
match.type === 'Match' &&
JSON.stringify(settings) === JSON.stringify(['Mode: All']) &&
JSON.stringify(listOutputs) === JSON.stringify(['done', 'items', 'indices']),
JSON.stringify({ find, match, settings, listOutputs }),
);
await select({ kind: 'node', assetId: 'plury', nodeId: find.id });
const modeField = await json(`__gm.inspectorField('Mode')`);
if (modeField) {
await click(modeField.x, modeField.y);
await clickText('Count');
}
const reshaped = await asset('plury');
check(
'picking Count in the inspector gives the Match done and count, and drops the edge on items',
reshaped.graph.nodes.find((n) => n.id === find.id)?.params.mode === 'count' &&
JSON.stringify(await outputsOf(match.id)) === JSON.stringify(['done', 'count']) &&
!reshaped.graph.edges.some((e) => e.id === 'pl-items'),
JSON.stringify({ modeField, edges: reshaped.graph.edges }),
);
await clickText('Fit');
await shot('26-find-modes');
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'plury');
const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite'));
Object.assign(sprite, { id: 'pl-sprite', name: 'Plural sprite' });
p.assets.push(sprite);
const node = (id, type, params = {}) => ({ id, type, x: 0, y: 0, params });
const edge = (from, fromPort, to, toPort) =>
({ id: from + '>' + to + '.' + toPort, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph = { nodes: [
node('pl-start', 'OnStart'),
node('pl-root', 'ObjectByName', { name: 'Crowd 7' }),
node('pl-named', 'ObjectsByName', { name: 'Bee' }),
node('pl-below', 'ChildrenByName', { name: 'Bee' }),
node('pl-direct', 'Children', { recursive: false }),
node('pl-all', 'Children', { recursive: true }),
node('pl-kind', 'ObjectsOfAsset', { asset: 'pl-sprite' }),
node('pl-find', 'Find', { mode: 'all' }),
node('pl-name', 'NameOf'),
node('pl-bee', 'Equals', { b: 'Bee' }),
node('pl-match', 'Match'),
...[1, 2, 3, 4, 5].map((i) => node('pl-n' + i, 'Count')),
...[1, 2, 3, 4, 5, 6].map((i) => node('pl-log' + i, 'Log')),
], edges: [
edge('pl-start', 'exec', 'pl-log1', 'exec'),
edge('pl-root', 'object', 'pl-below', 'object'),
edge('pl-root', 'object', 'pl-direct', 'object'),
edge('pl-root', 'object', 'pl-all', 'object'),
edge('pl-named', 'objects', 'pl-n1', 'list'),
edge('pl-below', 'children', 'pl-n2', 'list'),
edge('pl-direct', 'children', 'pl-n3', 'list'),
edge('pl-all', 'children', 'pl-n4', 'list'),
edge('pl-kind', 'objects', 'pl-n5', 'list'),
...[1, 2, 3, 4, 5].map((i) => edge('pl-n' + i, 'count', 'pl-log' + i, 'message')),
...[1, 2, 3, 4].map((i) => edge('pl-log' + i, 'exec', 'pl-log' + (i + 1), 'exec')),
edge('pl-log5', 'exec', 'pl-find', 'exec'),
edge('pl-all', 'children', 'pl-find', 'list'),
edge('pl-find', 'item', 'pl-name', 'object'),
edge('pl-name', 'name', 'pl-bee', 'a'),
edge('pl-bee', 'result', 'pl-match', 'match'),
edge('pl-match', 'done', 'pl-log6', 'exec'),
edge('pl-match', 'indices', 'pl-log6', 'message'),
] };
const scene = __gm.state.activeScene;
const place = (id, assetId, parentId, name) => {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
};
place('pl-holder', 'plury', '', 'Plural holder');
place('pl-crowd', 'pl-sprite', '', 'Crowd 7');
place('pl-a', 'pl-sprite', 'pl-crowd', 'Bee');
place('pl-b', 'pl-sprite', 'pl-a', 'Bee');
place('pl-c', 'pl-sprite', 'pl-crowd', 'Ant');
`);
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Plural holder]'))
.map((line) => line.split(']').pop().trim());
// By name 2 · below Crowd 2 · direct children 2 · all below 3 · of the asset 4 · Bees at 0 and 2.
check(
'run over Crowd 7 › (Bee › Bee), Ant: every plural lookup and the Filter answer as planned',
JSON.stringify(logged) === JSON.stringify(['2', '2', '2', '3', '4', '0, 2']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'plury' && a.id !== 'pl-sprite');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-'));
`);
}
// 6z-viii. Event order (#264): an event node shows no Order row at 0. Typed into the inspector, the
// order lands on the node and on its row, and a run starts the lower order first, even
// when its behavior stands lower in the hierarchy.
async function eventOrder() {
await mutate(`
const p = __gm.state.project;
const behavior = (id, name, message) => ({
id,
type: 'behavior',
name,
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
{ id: id + '-start', type: 'OnStart', x: 0, y: 0, params: { order: 0 } },
{ id: id + '-log', type: 'Log', x: 260, y: 0, params: { message } },
],
edges: [{ id: id + '-e', from: { node: id + '-start', port: 'exec' }, to: { node: id + '-log', port: 'exec' } }],
},
});
p.assets.push(behavior('ord-a', 'Order A', 'A'), behavior('ord-b', 'Order B', 'B'));
const scene = __gm.state.activeScene;
for (const [id, assetId, name] of [['ord-first', 'ord-a', 'Order first'], ['ord-second', 'ord-b', 'Order second']]) {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId: '', name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
}
__gm.state.select({ kind: 'asset', assetId: 'ord-b' });
`);
await showTab('graph');
await clickText('Fit');
const atRest = await json(`__gm.graph.nodeSettings('ord-b-start')`);
await select({ kind: 'node', assetId: 'ord-b', nodeId: 'ord-b-start' });
const orderField = await json(`__gm.inspectorField('Order')`);
if (orderField) {
await click(orderField.x, orderField.y);
await typeText('-1');
await press('Enter');
}
const params = (await asset('ord-b')).graph.nodes.find((n) => n.id === 'ord-b-start')?.params;
const shown = await json(`__gm.graph.nodeSettings('ord-b-start')`);
check(
'an event node shows no Order row at 0; -1 typed into the inspector lands on the node and its row',
JSON.stringify(atRest) === '[]' &&
params?.order === -1 &&
JSON.stringify(shown) === JSON.stringify(['Order: -1']),
JSON.stringify({ atRest, orderField, params, shown }),
);
await shot('27-event-order');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Order '))
.map((line) => line.split(']').pop().trim());
check(
'a run starts the behavior with Order -1 first, though it stands below the other one',
JSON.stringify(logged) === JSON.stringify(['B', 'A']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'ord-a' && a.id !== 'ord-b');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('ord-'));
`);
}
// 6z-ix. Animation playing (#233): typed into the palette it arrives with its ports. In a run it
// says true in the very pulse that plays a once animation, and false when that one finished.
async function animationPlaying() {
await mutate(`
const p = __gm.state.project;
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push(
{
id: 'ap-swing',
type: 'animation',
name: 'Swing 6',
props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'ap-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 2, value: 20 }] }],
},
{
id: 'apy',
type: 'behavior',
name: 'Asker',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
},
);
__gm.state.select({ kind: 'asset', assetId: 'apy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('animation playing');
await press('Enter');
const asker = (await asset('apy')).graph.nodes[0];
const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null;
const inputs = asker ? await json(`__gm.graph.nodeInputs(${JSON.stringify(asker.id)})`) : null;
check(
'typing "animation playing" into the palette adds the node: object and animation in, three answers out',
asker?.type === 'AnimationPlaying' &&
JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object', 'animation']) &&
JSON.stringify(types?.outputs) ===
JSON.stringify({ playing: 'boolean', progress: 'number', timeLeft: 'number' }) &&
inputs?.[1] === 'animation None',
JSON.stringify({ asker, types, inputs }),
);
if (!asker) return;
const id = JSON.stringify(asker.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'apy');
behavior.graph.nodes.find((n) => n.id === ${id}).params.animation = 'ap-swing';
const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('ap-start', 'OnStart', -300, -200),
node('ap-owner', 'Owner', -560, 0),
node('ap-play', 'PlayAnimation', -40, -200, { animation: 'ap-swing', blend: 0, blendOut: 0, restart: false }),
node('ap-said', 'Log', 420, -200),
node('ap-finished', 'OnAnimationFinished', 0, 260, { animation: 'ap-swing', order: 0 }),
node('ap-after', 'Log', 420, 260),
);
behavior.graph.edges.push(
edge('ap-e1', 'ap-start', 'exec', 'ap-play', 'exec'),
edge('ap-e2', 'ap-owner', 'object', 'ap-play', 'object'),
edge('ap-e3', 'ap-owner', 'object', ${id}, 'object'),
edge('ap-e4', 'ap-play', 'exec', 'ap-said', 'exec'),
edge('ap-e5', ${id}, 'playing', 'ap-said', 'message'),
edge('ap-e6', 'ap-finished', 'exec', 'ap-after', 'exec'),
edge('ap-e7', ${id}, 'playing', 'ap-after', 'message'),
);
const scene = __gm.state.activeScene;
const sprite = p.assets.find((a) => a.type === 'sprite');
for (const [id, assetId, parentId, name] of [
['ap-target', sprite.id, '', 'Swinger'],
['ap-holder', 'apy', 'ap-target', 'Ask holder'],
]) {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
}
`);
await clickText('Fit');
await shot('28-animation-playing');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(900);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Ask holder]'))
.map((line) => line.split(']').pop().trim());
check(
'a run hears true right after Play animation, and false once the animation finished',
JSON.stringify(logged) === JSON.stringify(['true', 'false']),
JSON.stringify(logged),
);
// #306: a long animation name gives way before the output label of its row.
await showTab('graph');
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'ap-swing').name = 'Swing with a name far too long for its row'`,
);
await shot('28b-animation-playing-long-name');
const row = (await json(`__gm.graph.rowSpans(${id})`))?.[1];
check(
'a long inline value is cut before the output label of its row, which stays whole',
row?.input?.text.endsWith('…') &&
'animation Swing with a name far too long for its row'.startsWith(row.input.text.slice(0, -1)) &&
row.output?.text === 'progress' &&
row.input.right + 6 <= row.output.left,
JSON.stringify(row),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('ap-'));
`);
}
// 6z-x. Layer of (#211): typed into the palette it arrives with its ports. In a run it names the
// layer of the owner's collider as the project calls it, and its number.
async function layerOf() {
await mutate(`
const p = __gm.state.project;
window.__layersBefore = p.collisionLayers;
p.collisionLayers = ['Default', 'Ghost'];
p.assets.push(
{
id: 'lo-col',
type: 'collider',
name: 'Ghost collider',
props: { shape: 'box', width: 20, height: 20, radius: 10, isTrigger: false, layer: 1 },
},
{
id: 'loy',
type: 'behavior',
name: 'Layer asker',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
},
);
__gm.state.select({ kind: 'asset', assetId: 'loy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('layer of');
await press('Enter');
const asker = (await asset('loy')).graph.nodes[0];
const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null;
check(
'typing "layer of" into the palette adds the node: object in, name and number out',
asker?.type === 'LayerOf' &&
JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object']) &&
JSON.stringify(types?.outputs) === JSON.stringify({ name: 'string', number: 'number' }),
JSON.stringify({ asker, types }),
);
if (!asker) return;
const id = JSON.stringify(asker.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'loy');
const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('lo-start', 'OnStart', -300, -200),
node('lo-owner', 'Owner', -560, 0),
node('lo-name', 'Log', 420, -200),
node('lo-number', 'Log', 700, -200),
);
behavior.graph.edges.push(
edge('lo-e1', 'lo-start', 'exec', 'lo-name', 'exec'),
edge('lo-e2', 'lo-name', 'exec', 'lo-number', 'exec'),
edge('lo-e3', 'lo-owner', 'object', ${id}, 'object'),
edge('lo-e4', ${id}, 'name', 'lo-name', 'message'),
edge('lo-e5', ${id}, 'number', 'lo-number', 'message'),
);
const scene = __gm.state.activeScene;
const sprite = p.assets.find((a) => a.type === 'sprite');
for (const [id, assetId, parentId, name, x] of [
['lo-target', sprite.id, '', 'Ghost 6', -6000],
['lo-part', 'lo-col', 'lo-target', 'Ghost part', 0],
['lo-holder', 'loy', 'lo-target', 'Layer holder', 0],
]) {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x, y: x, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
}
`);
await clickText('Fit');
await shot('29-layer-of');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Layer holder]'))
.map((line) => line.split(']').pop().trim());
check(
'a run names the layer of the owner’s collider the way the project calls it, and its number',
JSON.stringify(logged) === JSON.stringify(['Ghost', '1']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.collisionLayers = window.__layersBefore;
p.assets = p.assets.filter((a) => a.id !== 'loy' && a.id !== 'lo-col');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('lo-'));
`);
}
// 6z-xi. Links are picked on their line (#289): a click picks one and the inspector shows it,
// Ctrl+click adds another, a rubber band takes every line it crosses, and Delete or the
// line's own menu removes what is picked. The port keeps its Disconnect.
async function pickedLinks() {
await mutate(`
const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
const edge = (id, from, to, toPort) =>
({ id, from: { node: from, port: 'value' }, to: { node: to, port: toPort } });
__gm.state.project.assets.push({
id: 'lky',
type: 'behavior',
name: 'Link picker',
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
node('lk-one', 'Number', -300, -160, { value: 1 }),
node('lk-two', 'Number', -300, 160, { value: 2 }),
node('lk-add', 'Add', 200, 0),
],
edges: [edge('lk-e1', 'lk-one', 'lk-add', 'a'), edge('lk-e2', 'lk-two', 'lk-add', 'b')],
},
});
__gm.state.select({ kind: 'asset', assetId: 'lky' });
`);
await showTab('graph');
await clickText('Fit');
const edges = async () => (await asset('lky')).graph.edges.map((e) => e.id);
const drawn = () => json('__gm.graph.pickedEdges()');
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const at = async (id) => json(`__gm.graph.edgeAt(${JSON.stringify(id)})`);
const [one, two] = [await at('lk-e1'), await at('lk-e2')];
await click(one.x, one.y);
let s = await state();
await shot('30-link-picked');
check(
'clicking a link picks it: it is drawn picked and the inspector shows where it runs',
s.selection?.kind === 'edge' &&
s.selection.edgeId === 'lk-e1' &&
same(await drawn(), ['lk-e1']) &&
Boolean(await find('LINK')) &&
// Hiding and deleting live in the line's menu alone; the inspector only describes.
!(await find('Hide link')) &&
!(await find('Delete link')),
JSON.stringify({ selection: s.selection, drawn: await drawn() }),
);
await press('Delete');
const afterDelete = await edges();
await undo();
check(
'Delete removes exactly the picked link, and undo brings it back',
same(afterDelete, ['lk-e2']) && same(await edges(), ['lk-e1', 'lk-e2']),
JSON.stringify({ afterDelete, undone: await edges() }),
);
await click(one.x, one.y);
await click(two.x, two.y, 'left', { ctrl: true });
const both = await drawn();
// Off both lines and every node: a click on bare canvas lets go of them again.
await click(one.x, two.y + 60);
const none = await drawn();
await drag(one.x - 12, one.y - 30, two.x + 12, two.y + 30);
s = await state();
check(
'Ctrl+click adds a second link, and a rubber band across the lines takes both without a node',
same(both, ['lk-e1', 'lk-e2']) &&
same(none, []) &&
s.selection?.kind === 'edge' &&
same(s.selection.edgeIds, ['lk-e1', 'lk-e2']) &&
Boolean(await find('2 LINKS')),
JSON.stringify({ both, none, selection: s.selection }),
);
await click(one.x, two.y + 60);
await click(two.x, two.y, 'right');
const offered = Boolean(await find('Hide link'));
const entry = await locate('Delete link');
await click(entry.x, entry.y);
const afterMenu = await edges();
await undo();
const port = await json(`__gm.graph.portAt('lk-add', 'a', false)`);
await click(port.x, port.y, 'right');
const atPort = Boolean(await find('Disconnect'));
await press('Escape');
check(
'right-clicking a link offers to hide or delete it, and the port keeps its Disconnect',
offered && same(afterMenu, ['lk-e1']) && same(await edges(), ['lk-e1', 'lk-e2']) && atPort,
JSON.stringify({ offered, afterMenu, atPort }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'lky');
`);
}
// 6z-xii. A graph of many nodes stays quick to move in (#292): panning moves the picture without
// making a node anew, a dragged node takes its links along without one text being made,
// and zoomed in only the nodes in the picture are drawn at all.
async function largeGraph() {
await mutate(`
const nodes = [];
const edges = [];
for (let row = 0; row < 6; row++)
for (let col = 0; col < 8; col++) {
const id = 'big-' + row + '-' + col;
nodes.push({ id, type: col === 0 ? 'Number' : 'Add', x: col * 260, y: row * 160, params: col === 0 ? { value: 1 } : {} });
if (col > 0)
edges.push({ id: 'e-' + id, from: { node: 'big-' + row + '-' + (col - 1), port: col === 1 ? 'value' : 'result' }, to: { node: id, port: 'a' } });
}
__gm.state.project.assets.push({
id: 'bigy',
type: 'behavior',
name: 'Big graph',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes, edges },
});
__gm.state.select({ kind: 'asset', assetId: 'bigy' });
`);
await showTab('graph');
await clickText('Fit');
const made = () => json('__gm.graph.made()');
const drawn = () => json('__gm.graph.nodesDrawn()');
const rectOf = (id) => json(`__gm.graph.nodeRect(${JSON.stringify(id)})`);
const near = (a, b) => Math.abs(a - b) <= 1.5;
const area = await json('__gm.graph.canvasRect()');
const centre = { x: area.x + area.w / 2, y: area.y + area.h / 2 };
const allDrawn = await drawn();
// The far corners of the graph are really on screen: what is drawn there is not the bare canvas.
const colourAt = (p) => evaluate(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`);
const bare = await colourAt({ x: area.x + 3, y: area.y + 3 });
const corners = [];
for (const id of ['big-0-0', 'big-0-7', 'big-5-0', 'big-5-7'])
corners.push(await colourAt(await json(`__gm.graph.nodeAt(${JSON.stringify(id)})`)));
const before = { made: await made(), rect: await rectOf('big-2-3') };
await drag(centre.x, centre.y, centre.x + 80, centre.y + 50, { button: 'middle' });
const panned = { made: await made(), rect: await rectOf('big-2-3') };
check(
'panning a large graph moves the picture and makes no box, no text and no link anew',
allDrawn === 48 &&
corners.every((c) => c !== bare) &&
near(panned.rect.x - before.rect.x, 80) &&
near(panned.rect.y - before.rect.y, 50) &&
panned.made.box === before.made.box &&
panned.made.texts === before.made.texts &&
// Twelve pointer moves, and the links are drawn once: by the release, as after any gesture.
panned.made.links - before.made.links <= 1,
JSON.stringify({ allDrawn, bare, corners, before, panned }),
);
// Mid-drag, before the release has run its command: the node and its link are where the pointer is.
const grip = await json(`__gm.graph.nodeAt('big-2-3')`);
const linkBefore = await json(`__gm.graph.edgeAt('e-big-2-3')`);
await t.mouse('mouseMoved', grip.x, grip.y);
await t.mouse('mousePressed', grip.x, grip.y);
for (let i = 1; i <= 10; i++) {
await t.mouse('mouseMoved', grip.x + i * 6, grip.y + i * 4, 'left', { buttons: 1 });
await t.sleep(16);
}
await t.settle();
const during = {
made: await made(),
rect: await rectOf('big-2-3'),
link: await json(`__gm.graph.edgeAt('e-big-2-3')`),
status: await evaluate('__gm.state.saveStatus'),
};
await t.mouse('mouseReleased', grip.x + 60, grip.y + 40);
await t.settle();
const dropped = {
made: await made(),
node: (await asset('bigy')).graph.nodes.find((n) => n.id === 'big-2-3'),
};
await undo();
const undone = (await asset('bigy')).graph.nodes.find((n) => n.id === 'big-2-3');
await shot('31-large-graph');
check(
'a dragged node follows the pointer with its link, and no text is made anew – not for it, not on release',
near(during.rect.x - panned.rect.x, 60) &&
near(during.rect.y - panned.rect.y, 40) &&
during.link.x - linkBefore.x > 20 &&
during.made.texts === panned.made.texts &&
dropped.made.texts === panned.made.texts &&
// Its border changes with the selection, and nothing else is drawn again.
dropped.made.box - panned.made.box <= 2 &&
dropped.node.x !== 3 * 260 &&
undone.x === 3 * 260 &&
undone.y === 2 * 160,
JSON.stringify({ panned, during, linkBefore, dropped, undone }),
);
const corner = await json(`__gm.graph.nodeAt('big-0-0')`);
for (let i = 0; i < 14 && (await json('__gm.graph.zoom()')) < 1; i++)
await t.wheel(corner.x, corner.y, -120);
const zoomedIn = { zoom: await json('__gm.graph.zoom()'), drawn: await drawn(), made: await made() };
await shot('32-large-graph-near');
await clickText('Fit');
const fitted = { drawn: await drawn(), made: await made() };
const backAt = await colourAt(await json(`__gm.graph.nodeAt('big-5-7')`));
check(
'zoomed in, only the nodes in the picture are drawn; Fit brings all back without making one anew',
zoomedIn.zoom >= 1 &&
zoomedIn.drawn > 0 &&
zoomedIn.drawn < 20 &&
fitted.drawn === 48 &&
backAt !== bare &&
fitted.made.texts === zoomedIn.made.texts &&
fitted.made.box === zoomedIn.made.box &&
fitted.made.links === zoomedIn.made.links,
JSON.stringify({ zoomedIn, fitted, backAt, bare }),
);
// The other side of the same rule: what a node shows does change with its value.
const textsBefore = (await made()).texts;
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'bigy').graph.nodes.find((n) => n.id === 'big-0-0').params.value = 7;
`);
const shown = JSON.stringify([
await json(`__gm.graph.nodeSettings('big-0-0')`),
await json(`__gm.graph.nodeInputs('big-0-0')`),
]);
const textsAfter = (await made()).texts;
check(
'a node whose value changed shows the new one, and is the only one whose texts are made anew',
shown.includes('7') && textsAfter - textsBefore === 1,
JSON.stringify({ shown, textsBefore, textsAfter }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'bigy');
`);
}
}