Get/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).
The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
917 lines
38 KiB
JavaScript
917 lines
38 KiB
JavaScript
/**
|
||
* 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,
|
||
evaluate,
|
||
json,
|
||
find,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
mouse,
|
||
locate,
|
||
typeText,
|
||
press,
|
||
holdKey,
|
||
undo,
|
||
state,
|
||
select,
|
||
stop,
|
||
asset,
|
||
api,
|
||
navigate,
|
||
projectId,
|
||
} = 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(',');
|
||
|
||
// 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||
await clickText('Mover');
|
||
await evaluate(`__gm.showTab('graph')`);
|
||
await sleep(250);
|
||
check(
|
||
'graph editor shows the selected behavior',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph',
|
||
);
|
||
await shot('16-graph');
|
||
|
||
// 13a. 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')`);
|
||
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' &&
|
||
axisAfter.x === axisBefore.x + 60 &&
|
||
axisAfter.y === axisBefore.y + 40,
|
||
`${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}`,
|
||
);
|
||
|
||
// 13b. 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.
|
||
const gcenter = await json('__gm.graph.center()');
|
||
const beforeClickPick = await graphCounts();
|
||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||
await sleep(100);
|
||
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
await typeText('stop');
|
||
await sleep(150);
|
||
const stopRow = await locate('Stop game'); // nothing else on screen shows this label
|
||
await click(stopRow.x, stopRow.y);
|
||
await sleep(100);
|
||
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);
|
||
|
||
// 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||
const before13 = await graphCounts();
|
||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||
await sleep(100);
|
||
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
await typeText('log');
|
||
await sleep(100);
|
||
await press('Enter');
|
||
await sleep(250);
|
||
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');
|
||
|
||
// 13d. 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);
|
||
check(
|
||
'incompatible ports are rejected',
|
||
(await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit',
|
||
);
|
||
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 = { x: gcenter.x - 250, y: gcenter.y + 250 };
|
||
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);
|
||
await sleep(200);
|
||
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');
|
||
await sleep(150);
|
||
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),
|
||
);
|
||
|
||
// 13e. Delete removes the selected node together with its edge; undo brings both back.
|
||
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
|
||
await sleep(200);
|
||
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
|
||
await press('Delete');
|
||
await sleep(150);
|
||
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),
|
||
);
|
||
|
||
// 13f. 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 evaluate(`__gm.showTab('graph')`);
|
||
await sleep(100);
|
||
const holding = holdKey('ArrowRight', 300);
|
||
await sleep(250);
|
||
await shot('19-graph-live');
|
||
await holding;
|
||
await stop();
|
||
|
||
// 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(150);
|
||
await clickText('+ Variable');
|
||
await clickText('Number');
|
||
await sleep(100);
|
||
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());
|
||
|
||
// 13h. 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,
|
||
);
|
||
await select(null);
|
||
await sleep(100);
|
||
check(
|
||
'graph editor keeps its behavior when nothing is selected',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
await select({ kind: 'asset', assetId: 'cam' });
|
||
await sleep(100);
|
||
check(
|
||
'graph editor keeps its behavior for a non-behavior asset',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
// The picker is the *last* "Mover" on screen: the assets panel shows one too.
|
||
const pickerAt = await locate('Mover', true);
|
||
await click(pickerAt.x, pickerAt.y);
|
||
await sleep(150);
|
||
const spawnerRow = await locate('Spawner', true);
|
||
await click(spawnerRow.x, spawnerRow.y);
|
||
await sleep(200);
|
||
check(
|
||
'the header picker switches the graph to another behavior',
|
||
(await evaluate(`__gm.graph.current()`)) === 'spawner',
|
||
String(await evaluate(`__gm.graph.current()`)),
|
||
);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(100);
|
||
check(
|
||
'graph editor rebinds when a behavior is selected again',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
|
||
// 13i. 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');
|
||
await sleep(250);
|
||
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(),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
|
||
// 13j. 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');
|
||
|
||
// 13k. 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')`);
|
||
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 now.x === n.x + 40 && now.y === n.y + 30;
|
||
});
|
||
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. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
|
||
// so a screen delta is a graph delta.
|
||
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);
|
||
const snapLabel = await locate('Snap');
|
||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||
await click(snapLabel.x + 30, snapLabel.y);
|
||
await sleep(150);
|
||
check(
|
||
'the toggle switches it on without moving a single node',
|
||
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
|
||
await positions(),
|
||
);
|
||
|
||
// A single node: dropped between two grid lines, it lands on the nearer one.
|
||
await click(corner.x, corner.y);
|
||
await sleep(150);
|
||
const snapPre = await nodeOf('n-axis');
|
||
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
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 + 50)) <= GRID / 2 &&
|
||
Math.abs(snapped.y - (snapPre.y + 50)) <= 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')`);
|
||
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 + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= 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(snapLabel.x + 30, snapLabel.y);
|
||
await sleep(150);
|
||
await click(corner.x, corner.y);
|
||
await sleep(150);
|
||
const offPre = await nodeOf('n-axis');
|
||
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
|
||
const offPost = await nodeOf('n-axis');
|
||
check(
|
||
'switched off, a node lands exactly where it was dropped again',
|
||
(await json('__gm.graph.snapping()')) === false &&
|
||
offPost.x === offPre.x + 50 &&
|
||
offPost.y === offPre.y + 50,
|
||
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
|
||
);
|
||
await undo();
|
||
await sleep(150);
|
||
|
||
// 13l. 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');
|
||
await sleep(200);
|
||
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),
|
||
);
|
||
|
||
// 13m. 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');
|
||
await sleep(250);
|
||
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()')),
|
||
);
|
||
|
||
// 13n. 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.
|
||
await evaluate(
|
||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
|
||
);
|
||
await sleep(250);
|
||
await clickText('Fit');
|
||
await sleep(300);
|
||
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 -= 2500; __gm.state.preview(); })()`,
|
||
);
|
||
await clickText('Fit');
|
||
await sleep(250);
|
||
check('the parked node is back where it started', (await positions()) === before13i, await positions());
|
||
|
||
// 13o. 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');
|
||
await sleep(250);
|
||
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');
|
||
await sleep(150);
|
||
check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
|
||
await clickText('Hide link');
|
||
await sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(150);
|
||
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);
|
||
await sleep(200);
|
||
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);
|
||
await sleep(200);
|
||
check('selecting the node at the other end does too', (await drawn()) === true);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
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);
|
||
await sleep(200);
|
||
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' });
|
||
await sleep(250);
|
||
const shown = await edgeNow();
|
||
check(
|
||
'undo unhides the link, with nothing left behind',
|
||
shown.folded === undefined && (await drawn()) === true,
|
||
JSON.stringify(shown),
|
||
);
|
||
|
||
// 13p. 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');
|
||
await sleep(150);
|
||
check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
|
||
await clickText('Hide 2 links');
|
||
await sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
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);
|
||
await sleep(250);
|
||
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');
|
||
await sleep(150);
|
||
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 sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
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' });
|
||
await sleep(250);
|
||
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();
|
||
await sleep(200);
|
||
|
||
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');
|
||
await sleep(150);
|
||
const offered = await find('Disconnect 2 links');
|
||
if (offered) await click(offered.x, offered.y);
|
||
await sleep(250);
|
||
const cut = await forkedNow();
|
||
await undo();
|
||
await sleep(250);
|
||
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() }),
|
||
);
|
||
|
||
// 13q. 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');
|
||
await sleep(250);
|
||
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);
|
||
await sleep(200);
|
||
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', 'value', 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 sleep(150);
|
||
await clickText('Hide link');
|
||
await sleep(200);
|
||
}
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
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 sleep(150);
|
||
await click(title.x, title.y, 'right');
|
||
await sleep(150);
|
||
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();
|
||
await sleep(150);
|
||
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'] });
|
||
await sleep(150);
|
||
const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
|
||
await click(openTitle.x, openTitle.y, 'right');
|
||
await sleep(150);
|
||
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();
|
||
await sleep(150);
|
||
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 () => {
|
||
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
|
||
const saved = await api(`projects/${projectId}`);
|
||
return saved.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 evaluate(`__gm.showTab('graph')`);
|
||
await sleep(250);
|
||
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())));
|
||
|
||
await evaluate(`__gm.showTab('scene')`);
|
||
}
|