Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
7.8 KiB
JavaScript
207 lines
7.8 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,
|
|
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');
|
|
|
|
// 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: "+ Variable ▾" → 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 graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
|
|
await select(null);
|
|
await sleep(100);
|
|
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
|
|
await select({ kind: 'asset', assetId: 'cam' });
|
|
await sleep(100);
|
|
check(
|
|
'graph editor stays empty for a non-behavior asset',
|
|
(await evaluate(`__gm.graph.current()`)) === null,
|
|
);
|
|
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')`);
|
|
}
|