- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
135 lines
7.5 KiB
JavaScript
135 lines
7.5 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')`);
|
|
}
|