With the Chip and the Filter drawn as devices, a preset such as the Laser is about 3060 units wide. In a window of 1280 px a fit then hit the floor of 0.25 and cut the Notes node off on the left. - ZOOM_LIMITS.min goes from 0.25 to 0.125. The graph zooms without steps, so one more step is the floor halved. 0.125 is also the smallest step of the scene view. - It holds for the wheel, for Fit and for the node board of plugins. - The grid still hides itself below 8 px, so it does not flicker. Tests: - Unit plugins and graph-framing: 55 tests green, the floor test now expects 0.125. - e2e 06 (atx): ALL PASSED, 149 checks. Only a comment that named 0.25 changed there. - Gate (atx): CHECK EXIT 0, 285 files, 4377 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2233 lines
99 KiB
JavaScript
2233 lines
99 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,
|
||
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 can need a zoom below the 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 easing
|
||
// picked in the inspector – the choice an animation key has – lands on the node and on its
|
||
// setting rows: a curve brings its direction along.
|
||
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;
|
||
// Like on a key, linear has no Direction and no Curve to pick, in the inspector either.
|
||
const idleFields = [
|
||
await json(`__gm.inspectorField('Direction')`),
|
||
await json(`__gm.inspectorField('Curve')`),
|
||
];
|
||
check(
|
||
'typing "lerp" into the palette adds a linear Lerp from 0 to 1, without Direction or Curve',
|
||
lerp?.type === 'Lerp' &&
|
||
JSON.stringify(inputs) === JSON.stringify(['a 0', 'b 1', 't 0']) &&
|
||
JSON.stringify(settings) === JSON.stringify(['Easing: Linear']) &&
|
||
idleFields.every((f) => !f),
|
||
JSON.stringify({ lerp, inputs, settings, idleFields }),
|
||
);
|
||
const easeField = await json(`__gm.inspectorField('Easing')`);
|
||
if (easeField) {
|
||
await click(easeField.x, easeField.y);
|
||
await clickText('Cubic');
|
||
}
|
||
const directionField = await json(`__gm.inspectorField('Direction')`);
|
||
if (directionField) {
|
||
await click(directionField.x, directionField.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 "Cubic" and "Out" in the inspector sets the node and its setting rows',
|
||
picked?.params.ease === 'cubic' &&
|
||
picked.params.direction === 'out' &&
|
||
JSON.stringify(pickedRows) === JSON.stringify(['Easing: Cubic', 'Direction: Out']),
|
||
JSON.stringify({ easeField, directionField, 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');
|
||
`);
|
||
}
|
||
}
|