Files
feuerwerk/tools/e2e/06-graph.mjs
T
rootandClaude Opus 5 15ffcd5739 Left-align menu entries, add a Dropdown field, scrollable and searchable menus
Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.

Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.

New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:31:14 +02:00

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: 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 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')`);
}