Dragging an edge now rings the port it would connect to and snaps the line there. A drop on a node's body takes that node's first free compatible port, or the last compatible one when none is free, replacing its link. A drop into empty space opens the palette filtered to the node types the edge could reach at all, and the picked node arrives wired - node and link as one undo step, so a single undo takes back the whole gesture. The decision of what fits what lives in core/graph/dropTarget.ts as pure functions, tested directly; the canvas only draws the answer. The palette takes an optional filter rather than knowing about drags. Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
306 lines
12 KiB
JavaScript
306 lines
12 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')`)),
|
||
);
|
||
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 }),
|
||
);
|
||
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),
|
||
);
|
||
|
||
// 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',
|
||
);
|
||
await evaluate(`__gm.showTab('scene')`);
|
||
}
|