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