Ein Toggle in der Graph-Kopfzeile lässt gezogene Nodes auf dem 24er-Raster einrasten – derselben Größe, die den Hintergrund zeichnet. Einschalten bewegt nichts; die Regel greift erst beim nächsten Zug. Bei Mehrfachauswahl richtet sich jeder Node für sich aus, statt einem gemeinsamen Versatz zu folgen, der die alten krummen Abstände mitschleppen würde. Die Entscheidung liegt als reine Funktion in framing.ts, wo die übrige Graph-Geometrie schon wohnt, und ist dort wie im Browser gepinnt.
744 lines
31 KiB
JavaScript
744 lines
31 KiB
JavaScript
/**
|
||
* The behavior graph editor: binding to the selection, dragging nodes, the palette (row click
|
||
* and Enter), connecting ports, deleting, the live highlight while playing, variables.
|
||
*/
|
||
export async function graph(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
mouse,
|
||
locate,
|
||
typeText,
|
||
press,
|
||
holdKey,
|
||
undo,
|
||
state,
|
||
select,
|
||
stop,
|
||
asset,
|
||
} = t;
|
||
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
|
||
const graphCounts = async () => {
|
||
const g = (await asset('mover')).graph;
|
||
return { nodes: g.nodes.length, edges: g.edges.length };
|
||
};
|
||
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
|
||
|
||
// 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||
await clickText('Mover');
|
||
await evaluate(`__gm.showTab('graph')`);
|
||
await sleep(250);
|
||
check(
|
||
'graph editor shows the selected behavior',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph',
|
||
);
|
||
await shot('16-graph');
|
||
|
||
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||
const axisBefore = await nodeOf('n-axis');
|
||
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
|
||
let s = await state();
|
||
const axisAfter = await nodeOf('n-axis');
|
||
check(
|
||
'dragging a node selects and moves it',
|
||
s.selection?.kind === 'node' &&
|
||
s.selection.nodeId === 'n-axis' &&
|
||
axisAfter.x === axisBefore.x + 60 &&
|
||
axisAfter.y === axisBefore.y + 40,
|
||
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
|
||
);
|
||
check(
|
||
'node inspector shows the node',
|
||
Boolean(await evaluate(`__gm.locate('Axis input')`)) &&
|
||
Boolean(await evaluate(`__gm.locate('DATA NODE')`)),
|
||
);
|
||
await undo();
|
||
const axisUndone = await nodeOf('n-axis');
|
||
check(
|
||
'undo moves the node back',
|
||
axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y,
|
||
`${axisUndone.x},${axisUndone.y}`,
|
||
);
|
||
|
||
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node.
|
||
// (The search field's blur-commit once picked the first match and the row click picked
|
||
// it again → two nodes.)
|
||
const gcenter = await json('__gm.graph.center()');
|
||
const beforeClickPick = await graphCounts();
|
||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||
await sleep(100);
|
||
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
await typeText('stop');
|
||
await sleep(150);
|
||
const stopRow = await locate('Stop game'); // nothing else on screen shows this label
|
||
await click(stopRow.x, stopRow.y);
|
||
await sleep(100);
|
||
const afterClickPick = await graphCounts();
|
||
check(
|
||
'clicking a palette row adds exactly one node',
|
||
afterClickPick.nodes === beforeClickPick.nodes + 1,
|
||
JSON.stringify({ beforeClickPick, afterClickPick }),
|
||
);
|
||
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
await undo(); // so the next step finds the same empty spot and the expected graph
|
||
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
|
||
|
||
// 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||
const before13 = await graphCounts();
|
||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||
await sleep(100);
|
||
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
await typeText('log');
|
||
await sleep(100);
|
||
await press('Enter');
|
||
await sleep(250);
|
||
const after13 = await graphCounts();
|
||
s = await state();
|
||
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
|
||
check(
|
||
'palette adds a Log node and selects it',
|
||
after13.nodes === before13.nodes + 1 && newNode?.type === 'Log',
|
||
JSON.stringify({ after13, newNode }),
|
||
);
|
||
await shot('17-node-added');
|
||
|
||
// 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
|
||
// exec output must not connect to a data input: dragging onto "message" does nothing.
|
||
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
|
||
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
|
||
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
|
||
const connected = await graphCounts();
|
||
check(
|
||
'dragging port to port connects the nodes',
|
||
connected.edges === before13.edges + 1,
|
||
JSON.stringify(connected),
|
||
);
|
||
const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`);
|
||
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
|
||
check(
|
||
'incompatible ports are rejected',
|
||
(await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit',
|
||
);
|
||
await shot('18-connected');
|
||
|
||
// 13d2. A drop on the node's body instead of a port: it takes the first free compatible input,
|
||
// and the highlight names that port while the button is still down.
|
||
const axisX = await json(`__gm.graph.portAt('n-axis', 'x', true)`);
|
||
const logBody = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode.id)})`);
|
||
await mouse('mouseMoved', axisX.x, axisX.y);
|
||
await mouse('mousePressed', axisX.x, axisX.y);
|
||
await mouse('mouseMoved', toPort.x, toPort.y, 'left', { buttons: 1 });
|
||
await sleep(50);
|
||
const overExec = await json(`__gm.graph.dropTarget()`);
|
||
check('a number drag over an exec port predicts nothing', overExec === null, JSON.stringify(overExec));
|
||
await mouse('mouseMoved', logBody.x, logBody.y, 'left', { buttons: 1 });
|
||
await sleep(50);
|
||
const overBody = await json(`__gm.graph.dropTarget()`);
|
||
check(
|
||
'over the node body it predicts the first free compatible input',
|
||
overBody?.nodeId === newNode.id && overBody.port === 'message' && overBody.isOutput === false,
|
||
JSON.stringify(overBody),
|
||
);
|
||
await shot('18b-drop-prediction');
|
||
await mouse('mouseReleased', logBody.x, logBody.y);
|
||
await sleep(150);
|
||
const dropped = await graphCounts();
|
||
const messageEdge = (await asset('mover')).graph.edges.find(
|
||
(e) => e.to.node === newNode.id && e.to.port === 'message',
|
||
);
|
||
check(
|
||
'the drop connects exactly the predicted port',
|
||
dropped.edges === before13.edges + 2 && messageEdge?.from.port === 'x',
|
||
JSON.stringify({ dropped, messageEdge }),
|
||
);
|
||
check('the prediction is dropped with the drag', (await json(`__gm.graph.dropTarget()`)) === null);
|
||
await undo();
|
||
check('undo removes that edge again', (await graphCounts()).edges === before13.edges + 1);
|
||
|
||
// 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach,
|
||
// and the picked node arrives already wired – as one undo step.
|
||
const empty = { x: gcenter.x - 250, y: gcenter.y + 250 };
|
||
const beforeDrop = await graphCounts();
|
||
await mouse('mouseMoved', axisX.x, axisX.y);
|
||
await mouse('mousePressed', axisX.x, axisX.y);
|
||
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
|
||
await sleep(50);
|
||
await mouse('mouseReleased', empty.x, empty.y);
|
||
await sleep(150);
|
||
check('a drop into empty space opens the palette', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||
// "Restart game" takes exec only. Its label must not appear – and unlike "On update" it is not
|
||
// a node on the canvas, where `locate` would find it whatever the palette lists.
|
||
check(
|
||
'it lists a node that takes a number, not one that cannot',
|
||
Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)),
|
||
);
|
||
check(
|
||
'the dragged edge stays on screen while the palette decides',
|
||
(await json(`__gm.graph.danglingEdge()`)) === true,
|
||
);
|
||
await shot('18c-drop-into-empty');
|
||
const clampRow = await locate('Clamp');
|
||
await click(clampRow.x, clampRow.y);
|
||
await sleep(200);
|
||
const afterDrop = await graphCounts();
|
||
const wired = await state();
|
||
const clampEdge = (await asset('mover')).graph.edges.find(
|
||
(e) => e.from.node === 'n-axis' && e.from.port === 'x' && e.to.node === wired.selection?.nodeId,
|
||
);
|
||
check(
|
||
'the new node arrives connected to its first compatible input',
|
||
afterDrop.nodes === beforeDrop.nodes + 1 &&
|
||
afterDrop.edges === beforeDrop.edges + 1 &&
|
||
clampEdge?.to.port === 'value',
|
||
JSON.stringify({ afterDrop, clampEdge }),
|
||
);
|
||
check(
|
||
'and the dangling edge gives way to the real one',
|
||
(await json(`__gm.graph.danglingEdge()`)) === false,
|
||
);
|
||
await undo();
|
||
const undone = await graphCounts();
|
||
check(
|
||
'one undo takes back both the node and its edge',
|
||
undone.nodes === beforeDrop.nodes && undone.edges === beforeDrop.edges,
|
||
JSON.stringify(undone),
|
||
);
|
||
|
||
// Cancelling the palette has to take the dangling edge with it, or it would hang there forever.
|
||
await mouse('mouseMoved', axisX.x, axisX.y);
|
||
await mouse('mousePressed', axisX.x, axisX.y);
|
||
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
|
||
await mouse('mouseReleased', empty.x, empty.y);
|
||
await sleep(150);
|
||
await press('Escape');
|
||
await sleep(150);
|
||
const cancelled = await graphCounts();
|
||
check(
|
||
'escaping the palette clears the edge and adds nothing',
|
||
(await json(`__gm.graph.danglingEdge()`)) === false &&
|
||
cancelled.nodes === beforeDrop.nodes &&
|
||
cancelled.edges === beforeDrop.edges,
|
||
JSON.stringify(cancelled),
|
||
);
|
||
|
||
// 13e. Delete removes the selected node together with its edge; undo brings both back.
|
||
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
|
||
await press('Delete');
|
||
await sleep(150);
|
||
const deleted = await graphCounts();
|
||
check(
|
||
'Delete removes the node and its edge',
|
||
deleted.nodes === before13.nodes && deleted.edges === before13.edges,
|
||
JSON.stringify(deleted),
|
||
);
|
||
await undo();
|
||
const restored = await graphCounts();
|
||
check(
|
||
'undo restores node and edge',
|
||
restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1,
|
||
JSON.stringify(restored),
|
||
);
|
||
|
||
// 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
|
||
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
|
||
const fpsEdit = await evaluate('__gm.fps()');
|
||
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
|
||
// Single evaluate: the readout refreshes twice a second, so read value and label together.
|
||
check(
|
||
'fps label is visible in the toolbar',
|
||
(await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true,
|
||
);
|
||
await t.play();
|
||
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
|
||
await sleep(600);
|
||
const fpsPlay = await evaluate('__gm.fps()');
|
||
check(
|
||
'fps readout keeps running in play mode',
|
||
typeof fpsPlay === 'number' && fpsPlay > 0,
|
||
`fps=${fpsPlay}`,
|
||
);
|
||
await evaluate(`__gm.showTab('graph')`);
|
||
await sleep(100);
|
||
const holding = holdKey('ArrowRight', 300);
|
||
await sleep(250);
|
||
await shot('19-graph-live');
|
||
await holding;
|
||
await stop();
|
||
|
||
// 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(150);
|
||
await clickText('+ Variable');
|
||
await clickText('Number');
|
||
await sleep(100);
|
||
check(
|
||
'adding a variable declares it on the behavior',
|
||
(await variableKeys()) === 'speed,var1',
|
||
await variableKeys(),
|
||
);
|
||
await shot('20-variable-added');
|
||
await undo();
|
||
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
|
||
|
||
// 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the
|
||
// header picker switches behaviors without any selection at all.
|
||
const size = await graphCounts();
|
||
const readout = `${size.nodes} nodes · ${size.edges} links`;
|
||
check(
|
||
'the header reports the size of the shown graph',
|
||
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
|
||
readout,
|
||
);
|
||
await select(null);
|
||
await sleep(100);
|
||
check(
|
||
'graph editor keeps its behavior when nothing is selected',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
await select({ kind: 'asset', assetId: 'cam' });
|
||
await sleep(100);
|
||
check(
|
||
'graph editor keeps its behavior for a non-behavior asset',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
// The picker is the *last* "Mover" on screen: the assets panel shows one too.
|
||
const pickerAt = await locate('Mover', true);
|
||
await click(pickerAt.x, pickerAt.y);
|
||
await sleep(150);
|
||
const spawnerRow = await locate('Spawner', true);
|
||
await click(spawnerRow.x, spawnerRow.y);
|
||
await sleep(200);
|
||
check(
|
||
'the header picker switches the graph to another behavior',
|
||
(await evaluate(`__gm.graph.current()`)) === 'spawner',
|
||
String(await evaluate(`__gm.graph.current()`)),
|
||
);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(100);
|
||
check(
|
||
'graph editor rebinds when a behavior is selected again',
|
||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||
);
|
||
|
||
// 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
|
||
// under it included – and the left button no longer pans at all.
|
||
await clickText('Fit');
|
||
await sleep(250);
|
||
const positions = async () =>
|
||
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
|
||
const area = await json('__gm.graph.canvasRect()');
|
||
const before13i = await positions();
|
||
const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, {
|
||
button: 'middle',
|
||
});
|
||
const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
check(
|
||
'the middle button pans even when it starts on a node',
|
||
Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 &&
|
||
Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3,
|
||
JSON.stringify({ axisScreenBefore, axisScreenPanned }),
|
||
);
|
||
check(
|
||
'panning over a node leaves the node where it was',
|
||
(await positions()) === before13i,
|
||
await positions(),
|
||
);
|
||
|
||
// An empty spot: after a fit every node sits at least a margin away from the corner.
|
||
const corner = { x: area.x + 6, y: area.y + 6 };
|
||
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
|
||
const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(corner.x, corner.y, corner.x + 60, corner.y + 40);
|
||
const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
check(
|
||
'dragging with the left button no longer pans',
|
||
Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3,
|
||
JSON.stringify({ axisBeforeBand, axisAfterBand }),
|
||
);
|
||
|
||
// 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
|
||
// so this cannot agree with the canvas by sharing its rule.
|
||
const nodeCount = (await asset('mover')).graph.nodes.length;
|
||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||
const banded = await json('__gm.graph.selected()');
|
||
check(
|
||
'a rubber band selects every node it spans',
|
||
banded.length === nodeCount,
|
||
`${banded.length} of ${nodeCount}`,
|
||
);
|
||
await shot('21-graph-multiselect');
|
||
|
||
// 13k. The whole selection moves as one, and one undo takes all of it back.
|
||
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
|
||
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
|
||
const afterMove = await asset('mover');
|
||
const movedTogether = beforeMove.every((n) => {
|
||
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
|
||
return now.x === n.x + 40 && now.y === n.y + 30;
|
||
});
|
||
check('dragging one of them moves the whole selection', movedTogether, await positions());
|
||
await undo();
|
||
check('one undo takes the group move back', (await positions()) === before13i, await positions());
|
||
|
||
// 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
|
||
// so a screen delta is a graph delta.
|
||
const GRID = 24;
|
||
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
|
||
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
|
||
const snapLabel = await locate('Snap');
|
||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||
await click(snapLabel.x + 30, snapLabel.y);
|
||
await sleep(150);
|
||
check(
|
||
'the toggle switches it on without moving a single node',
|
||
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
|
||
await positions(),
|
||
);
|
||
|
||
// A single node: dropped between two grid lines, it lands on the nearer one.
|
||
await click(corner.x, corner.y);
|
||
await sleep(150);
|
||
const snapPre = await nodeOf('n-axis');
|
||
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
|
||
const snapped = await nodeOf('n-axis');
|
||
check(
|
||
'a dragged node lands on the grid, not where the pointer let go',
|
||
onGrid(snapped) &&
|
||
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
|
||
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
|
||
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
|
||
);
|
||
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
|
||
check(
|
||
'the nodes it was dragged past stay off the grid',
|
||
others.some((n) => !onGrid(n)),
|
||
JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)),
|
||
);
|
||
|
||
// A group: every selected node snaps for itself, so the whole selection ends up on grid lines
|
||
// – off-grid gaps inside it close, rather than travelling along.
|
||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||
const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
|
||
check(
|
||
'the selection is not on the grid yet',
|
||
groupPre.some((n) => !onGrid(n)),
|
||
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
|
||
);
|
||
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
|
||
const groupPost = (await asset('mover')).graph.nodes;
|
||
check(
|
||
'every selected node lines up on the grid for itself, none by more than half a step',
|
||
groupPost.every(onGrid) &&
|
||
groupPre.every((n) => {
|
||
const now = groupPost.find((m) => m.id === n.id);
|
||
return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2;
|
||
}),
|
||
await positions(),
|
||
);
|
||
await undo();
|
||
await undo();
|
||
check('undo takes both snapped moves back', (await positions()) === before13i, await positions());
|
||
|
||
// Switching it off has to give the pointer its exact drop position back.
|
||
await click(snapLabel.x + 30, snapLabel.y);
|
||
await sleep(150);
|
||
await click(corner.x, corner.y);
|
||
await sleep(150);
|
||
const offPre = await nodeOf('n-axis');
|
||
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
|
||
const offPost = await nodeOf('n-axis');
|
||
check(
|
||
'switched off, a node lands exactly where it was dropped again',
|
||
(await json('__gm.graph.snapping()')) === false &&
|
||
offPost.x === offPre.x + 50 &&
|
||
offPost.y === offPre.y + 50,
|
||
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
|
||
);
|
||
await undo();
|
||
await sleep(150);
|
||
|
||
// 13l. Delete acts on the selection, not just the primary node.
|
||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||
const beforeDelete = await graphCounts();
|
||
await press('Delete');
|
||
await sleep(200);
|
||
const afterDelete = await graphCounts();
|
||
check(
|
||
'Delete removes every selected node, edges with them',
|
||
afterDelete.nodes === 0 && afterDelete.edges === 0,
|
||
JSON.stringify(afterDelete),
|
||
);
|
||
await undo();
|
||
const restoredAll = await graphCounts();
|
||
check(
|
||
'one undo brings the whole graph back',
|
||
restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges,
|
||
JSON.stringify(restoredAll),
|
||
);
|
||
|
||
// 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
|
||
// and a click cannot miss one that the earlier pan pushed over the edge.
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await clickText('Fit');
|
||
await sleep(250);
|
||
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
|
||
await click(axisRow.x, axisRow.y);
|
||
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis');
|
||
const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`);
|
||
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
|
||
check(
|
||
'ctrl-click adds a second node to the selection',
|
||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]),
|
||
JSON.stringify(await json('__gm.graph.selected()')),
|
||
);
|
||
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
|
||
check(
|
||
'ctrl-clicking it again removes it',
|
||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']),
|
||
JSON.stringify(await json('__gm.graph.selected()')),
|
||
);
|
||
|
||
// 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
|
||
// graph is then wider than the canvas – otherwise centring alone would pass this check.
|
||
await evaluate(
|
||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
|
||
);
|
||
await sleep(250);
|
||
await clickText('Fit');
|
||
await sleep(300);
|
||
const rects = [];
|
||
for (const node of (await asset('mover')).graph.nodes)
|
||
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
|
||
const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x));
|
||
check(
|
||
'the parked node makes the graph wider than the canvas, so fitting has to zoom out',
|
||
span > area.w * 0.5,
|
||
`span=${Math.round(span)} canvas=${Math.round(area.w)}`,
|
||
);
|
||
const outside = rects.filter(
|
||
(r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h,
|
||
);
|
||
check(
|
||
'Fit leaves no node hanging out of the view',
|
||
outside.length === 0,
|
||
`${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`,
|
||
);
|
||
await shot('22-graph-fitted');
|
||
await evaluate(
|
||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`,
|
||
);
|
||
await clickText('Fit');
|
||
await sleep(250);
|
||
check('the parked node is back where it started', (await positions()) === before13i, await positions());
|
||
|
||
// 13o. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
|
||
// comes back while either of its two nodes is selected.
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await clickText('Fit');
|
||
await sleep(250);
|
||
const allEdges = (await asset('mover')).graph.edges;
|
||
const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0];
|
||
const id = JSON.stringify(edge.id);
|
||
const drawn = async () => await json(`__gm.graph.edgeDrawn(${id})`);
|
||
const edgeNow = async () => (await asset('mover')).graph.edges.find((e) => e.id === edge.id);
|
||
check('the link is drawn as a curve to start with', (await drawn()) === true);
|
||
|
||
const outPort = await json(
|
||
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
|
||
);
|
||
await click(outPort.x, outPort.y, 'right');
|
||
await sleep(150);
|
||
check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
|
||
await clickText('Hide link');
|
||
await sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(150);
|
||
const hidden = await edgeNow();
|
||
check(
|
||
'hiding changes nothing but a flag – the connection is still there',
|
||
Boolean(hidden) &&
|
||
hidden.folded === true &&
|
||
hidden.from.node === edge.from.node &&
|
||
hidden.to.node === edge.to.node,
|
||
JSON.stringify(hidden),
|
||
);
|
||
check('and the curve is gone from the canvas', (await drawn()) === false);
|
||
const mark = await json(`__gm.graph.foldMark(${id}, true)`);
|
||
check(
|
||
'a stub with a mark takes its place at the port',
|
||
mark !== null && Math.abs(mark.x - outPort.x) < 40 && Math.abs(mark.y - outPort.y) < 8,
|
||
JSON.stringify({ mark, outPort }),
|
||
);
|
||
await shot('23-link-hidden');
|
||
|
||
// Selecting either end lends the curve back – the source first, then the target.
|
||
const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`);
|
||
await click(sourceAt.x, sourceAt.y);
|
||
await sleep(200);
|
||
check('selecting the node at one end brings the curve back', (await drawn()) === true);
|
||
const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`);
|
||
await click(targetAt.x, targetAt.y);
|
||
await sleep(200);
|
||
check('selecting the node at the other end does too', (await drawn()) === true);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
check('deselecting hides it again', (await drawn()) === false);
|
||
|
||
// The mark is the handle: clicking it selects the node at the far end, which is also what
|
||
// puts the curve back – so it answers "where does this go?" in one click.
|
||
const mark2 = await json(`__gm.graph.foldMark(${id}, true)`);
|
||
await click(mark2.x, mark2.y);
|
||
await sleep(200);
|
||
check(
|
||
'clicking the mark jumps to the node at the far end',
|
||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]),
|
||
JSON.stringify(await json('__gm.graph.selected()')),
|
||
);
|
||
check('which shows the curve again', (await drawn()) === true);
|
||
|
||
await undo();
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(250);
|
||
const shown = await edgeNow();
|
||
check(
|
||
'undo unhides the link, with nothing left behind',
|
||
shown.folded === undefined && (await drawn()) === true,
|
||
JSON.stringify(shown),
|
||
);
|
||
|
||
// 13p. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
|
||
// the very same pixels – the names smearing over each other. One port, one mark.
|
||
const before13p = await graphCounts();
|
||
const forkPort = await json(
|
||
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
|
||
);
|
||
// A spot no node covers, measured rather than guessed: after the Fit above, where the nodes
|
||
// sit on screen is not something this step gets to assume.
|
||
const canvasArea = await json('__gm.graph.canvasRect()');
|
||
const taken = [];
|
||
for (const node of (await asset('mover')).graph.nodes)
|
||
taken.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
|
||
const freeSpots = [
|
||
{ x: canvasArea.x + 60, y: canvasArea.y + canvasArea.h - 60 },
|
||
{ x: canvasArea.x + canvasArea.w - 60, y: canvasArea.y + canvasArea.h - 60 },
|
||
{ x: canvasArea.x + 60, y: canvasArea.y + 60 },
|
||
];
|
||
const free =
|
||
freeSpots.find((p) =>
|
||
taken.every(
|
||
(r) => !r || p.x < r.x - 10 || p.y < r.y - 10 || p.x > r.x + r.w + 10 || p.y > r.y + r.h + 10,
|
||
),
|
||
) ?? freeSpots[0];
|
||
// The same gesture as 13d3: dropped into empty space the palette offers only what this port can
|
||
// reach, and the picked node arrives already wired – so no port name has to be guessed here.
|
||
await mouse('mouseMoved', forkPort.x, forkPort.y);
|
||
await mouse('mousePressed', forkPort.x, forkPort.y);
|
||
await mouse('mouseMoved', free.x, free.y, 'left', { buttons: 1 });
|
||
await sleep(50);
|
||
await mouse('mouseReleased', free.x, free.y);
|
||
await sleep(200);
|
||
const forkRow = await locate('Clamp');
|
||
if (forkRow) await click(forkRow.x, forkRow.y);
|
||
await sleep(250);
|
||
const twin = (await state()).selection?.nodeId;
|
||
const forked = (await asset('mover')).graph.edges.filter(
|
||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||
);
|
||
check(
|
||
'a second link now leaves the same port',
|
||
forked.length === 2 && (await graphCounts()).nodes === before13p.nodes + 1,
|
||
JSON.stringify({ forked: forked.length, twin, free, row: Boolean(forkRow) }),
|
||
);
|
||
|
||
await click(forkPort.x, forkPort.y, 'right');
|
||
await sleep(150);
|
||
check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
|
||
await clickText('Hide 2 links');
|
||
await sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
const ids = forked.map((e) => JSON.stringify(e.id));
|
||
const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`);
|
||
const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`);
|
||
check(
|
||
'both hidden links share one mark at the port instead of stacking two',
|
||
markA !== null && markA.x === markB.x && markA.y === markB.y && markA.links === 2,
|
||
JSON.stringify({ markA, markB }),
|
||
);
|
||
const marks = await json(`__gm.graph.foldMarks()`);
|
||
check(
|
||
'and only three marks are drawn for the two links: one shared, one at each far end',
|
||
marks.length === 3 && marks.filter((m) => m.links === 2).length === 1,
|
||
JSON.stringify(marks),
|
||
);
|
||
check(
|
||
'the shared mark names one far node and counts the rest',
|
||
markA.label.endsWith('+1') && markA.label.length > 2,
|
||
JSON.stringify(markA.label),
|
||
);
|
||
await shot('24-links-bundled');
|
||
|
||
// The bundled mark is still a handle: one click selects every far end, which is also what
|
||
// lends all of its curves back at once.
|
||
await click(markA.x, markA.y);
|
||
await sleep(250);
|
||
const picked = await json('__gm.graph.selected()');
|
||
check(
|
||
'clicking a bundled mark selects every node the port feeds',
|
||
picked.length === 2 && picked.includes(edge.to.node) && picked.includes(twin),
|
||
JSON.stringify(picked),
|
||
);
|
||
check(
|
||
'and both curves come back at once',
|
||
(await json(`__gm.graph.edgeDrawn(${ids[0]})`)) === true &&
|
||
(await json(`__gm.graph.edgeDrawn(${ids[1]})`)) === true,
|
||
);
|
||
|
||
await click(markA.x, markA.y, 'right');
|
||
await sleep(150);
|
||
check(
|
||
'the mark menu shows both links back in one go',
|
||
Boolean(await evaluate(`__gm.locate('Show 2 links')`)),
|
||
);
|
||
await clickText('Show 2 links');
|
||
await sleep(250);
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(200);
|
||
const unfolded = (await asset('mover')).graph.edges.filter(
|
||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||
);
|
||
check(
|
||
'showing them again leaves no fold behind',
|
||
unfolded.every((e) => e.folded === undefined) && (await json(`__gm.graph.foldMarks()`)).length === 0,
|
||
JSON.stringify(unfolded),
|
||
);
|
||
// One step of undo takes both back – hiding two links was one command, so it is one undo.
|
||
await undo();
|
||
await select({ kind: 'asset', assetId: 'mover' });
|
||
await sleep(250);
|
||
const refolded = (await asset('mover')).graph.edges.filter(
|
||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||
);
|
||
check(
|
||
'and undo re-hides both as one step',
|
||
refolded.length === 2 && refolded.every((e) => e.folded === true),
|
||
JSON.stringify(refolded.map((e) => e.folded)),
|
||
);
|
||
await undo();
|
||
await sleep(200);
|
||
|
||
await evaluate(`__gm.showTab('scene')`);
|
||
}
|