Files
feuerwerk/tools/e2e/06-graph.mjs
T
rootandClaude Opus 5 f16801aef3 Add a decimals option, fix dropdown overflow and the texture filter
- NumberField takes a decimals option (default 3); frame rects and the
  slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
  chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
  the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
  frame, the former editor is GraphCanvas. A selection the tab does not
  concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:05:28 +02:00

232 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: 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. A selection about something else (nothing, a camera) leaves
// the graph alone – clicking a sprite to look at it must not empty the editor you are
// working in – 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, and the
// inspector is on the camera.
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')`);
}