/** * 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, saved, evaluate, json, find, shot, click, clickText, drag, mouse, locate, mutate, typeText, press, holdKey, undo, state, select, stop, asset, api, navigate, projectId, showTab, } = 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(','); // 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it. await clickText('Mover'); await showTab('graph'); check( 'graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph', ); await shot('16-graph'); // 6b. 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')`); // The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60. const fitZoom = Number(await evaluate('__gm.graph.zoom()')); 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' && Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 && Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2, `${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}`, ); // 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's // blur-commit must not pick the first match as well. // Bare canvas, searched rather than guessed: the console panel below shortens the canvas. const bare = await json('__gm.graph.emptySpot()'); const beforeClickPick = await graphCounts(); await click(bare.x, bare.y, 'right'); check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); await typeText('stop'); const stopRow = await locate('Stop game'); // nothing else on screen shows this label await click(stopRow.x, stopRow.y); 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); // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. const before13 = await graphCounts(); await click(bare.x, bare.y, 'right'); check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); // Unfiltered the palette holds far more nodes than fit, so it grows to the full height it may // have – and that height is the canvas minus a tenth at each end. Shifting it up to fit used to // put the entire gap above it and leave it flush on the bottom edge. Opened this far down it ends // up with a tenth at both ends; the gap above is what is left over, not a rule of its own. const paletteBox = await json('__gm.overlayRect()'); const paletteScreenH = await json('__gm.app.screen.height'); const paletteMargin = Math.round(paletteScreenH * 0.1); const gapAbove = paletteBox.y; const gapBelow = paletteScreenH - (paletteBox.y + paletteBox.h); check( 'a palette shifted up to fit keeps the tenth of the canvas below it', Math.abs(gapBelow - paletteMargin) <= 1 && gapAbove >= 0, JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }), ); await typeText('log'); await press('Enter'); 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'); // 6d2. The palette ranks by how well an entry answers the query (#43): a node *named* after it // comes before one that merely mentions it in its description. "variable" used to hand // Enter the first row alphabetically, which was `Run behavior`. const beforeRank = await graphCounts(); // A fresh spot: the Log node from 6d now sits where `bare` pointed, and whether a click there // hits it depends on how the layout snaps the node, not on anything this step tests. const bareRank = await json('__gm.graph.emptySpot()'); await click(bareRank.x, bareRank.y, 'right'); await typeText('variable'); const ranked = await json('__gm.graph.paletteRows()'); const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable'); const lastNamed = ranked.map(hits).lastIndexOf(true); const firstOther = ranked.map(hits).indexOf(false); check( 'the palette lists the nodes named after the query first', lastNamed >= 0 && (firstOther === -1 || lastNamed < firstOther) && hits(ranked[0]), JSON.stringify(ranked), ); await press('Enter'); s = await state(); const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; check( 'Enter takes that node, not the one whose description mentions it', rankPick !== null && rankPick.type.toLowerCase().includes('variable'), JSON.stringify(rankPick), ); await undo(); check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes); // 6e. 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); // Released on the port itself, which sits at the node's very edge: that is a drop on the node, not // on empty canvas. An open palette here swallows every later click – it did, for four checks. check( 'incompatible ports are rejected without taking the drop for empty canvas', (await graphCounts()).edges === before13.edges + 1 && (await state()).mode === 'edit' && !(await evaluate(`__gm.locate('Search nodes…')`)), ); await shot('18-connected'); // 13d2. A drop on the node's body instead of a port: it takes the first free compatible input, // and the highlight names that port while the button is still down. const axisX = await json(`__gm.graph.portAt('n-axis', 'x', true)`); const logBody = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode.id)})`); await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', toPort.x, toPort.y, 'left', { buttons: 1 }); await sleep(50); const overExec = await json(`__gm.graph.dropTarget()`); check('a number drag over an exec port predicts nothing', overExec === null, JSON.stringify(overExec)); await mouse('mouseMoved', logBody.x, logBody.y, 'left', { buttons: 1 }); await sleep(50); const overBody = await json(`__gm.graph.dropTarget()`); check( 'over the node body it predicts the first free compatible input', overBody?.nodeId === newNode.id && overBody.port === 'message' && overBody.isOutput === false, JSON.stringify(overBody), ); await shot('18b-drop-prediction'); await mouse('mouseReleased', logBody.x, logBody.y); await sleep(150); const dropped = await graphCounts(); const messageEdge = (await asset('mover')).graph.edges.find( (e) => e.to.node === newNode.id && e.to.port === 'message', ); check( 'the drop connects exactly the predicted port', dropped.edges === before13.edges + 2 && messageEdge?.from.port === 'x', JSON.stringify({ dropped, messageEdge }), ); check('the prediction is dropped with the drag', (await json(`__gm.graph.dropTarget()`)) === null); await undo(); check('undo removes that edge again', (await graphCounts()).edges === before13.edges + 1); // 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach, // and the picked node arrives already wired – as one undo step. const empty = await json('__gm.graph.emptySpot()'); const beforeDrop = await graphCounts(); await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); await sleep(50); await mouse('mouseReleased', empty.x, empty.y); await sleep(150); check('a drop into empty space opens the palette', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); // "Restart game" takes exec only. Its label must not appear – and unlike "On update" it is not // a node on the canvas, where `locate` would find it whatever the palette lists. check( 'it lists a node that takes a number, not one that cannot', Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)), ); check( 'the dragged edge stays on screen while the palette decides', (await json(`__gm.graph.danglingEdge()`)) === true, ); await shot('18c-drop-into-empty'); const clampRow = await locate('Clamp'); await click(clampRow.x, clampRow.y); const afterDrop = await graphCounts(); const wired = await state(); const clampEdge = (await asset('mover')).graph.edges.find( (e) => e.from.node === 'n-axis' && e.from.port === 'x' && e.to.node === wired.selection?.nodeId, ); check( 'the new node arrives connected to its first compatible input', afterDrop.nodes === beforeDrop.nodes + 1 && afterDrop.edges === beforeDrop.edges + 1 && clampEdge?.to.port === 'value', JSON.stringify({ afterDrop, clampEdge }), ); check( 'and the dangling edge gives way to the real one', (await json(`__gm.graph.danglingEdge()`)) === false, ); await undo(); const undone = await graphCounts(); check( 'one undo takes back both the node and its edge', undone.nodes === beforeDrop.nodes && undone.edges === beforeDrop.edges, JSON.stringify(undone), ); // Cancelling the palette has to take the dangling edge with it, or it would hang there forever. await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); await mouse('mouseReleased', empty.x, empty.y); await sleep(150); await press('Escape'); const cancelled = await graphCounts(); check( 'escaping the palette clears the edge and adds nothing', (await json(`__gm.graph.danglingEdge()`)) === false && cancelled.nodes === beforeDrop.nodes && cancelled.edges === beforeDrop.edges, JSON.stringify(cancelled), ); // 6f. Delete removes the selected node together with its edge; undo brings both back. await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id }); check('the node inspector has no delete button of its own', !(await t.has('Delete node'))); await press('Delete'); 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), ); // 6g. 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}`); check( 'the canvas bypasses the compositor queue by default', (await evaluate('__gm.lowLatency()')) === true, ); // 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 showTab('graph'); const holding = holdKey('ArrowRight', 300); await sleep(250); await shot('19-graph-live'); await holding; await stop(); // 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it. await select({ kind: 'asset', assetId: 'mover' }); await clickText('+ Variable'); await clickText('Number'); 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()); // 6i. 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, ); check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…'))); await select(null); check( 'graph editor keeps its behavior when nothing is selected', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await select({ kind: 'asset', assetId: 'cam' }); check( 'graph editor keeps its behavior for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await select({ kind: 'asset', assetId: 'spawner' }); check( 'selecting another behavior leaves the tab on its own (#171)', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await evaluate(`__gm.state.openAsset('spawner')`); const twoOpen = await json('__gm.documents()'); check( 'opening it brings it up in a tab of its own', (await evaluate(`__gm.graph.current()`)) === 'spawner' && twoOpen.active === 'asset:spawner' && twoOpen.docs.includes('asset:mover'), JSON.stringify(twoOpen), ); await evaluate(`__gm.closeDocument('asset:spawner')`); check( 'closing it shows the behavior next to it again', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await json('__gm.tab()')) === 'graph', String(await evaluate(`__gm.graph.current()`)), ); await select({ kind: 'asset', assetId: 'mover' }); // 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node // under it included – and the left button no longer pans at all. await clickText('Fit'); const positions = async () => (await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' '); const area = await json('__gm.graph.canvasRect()'); const before13i = await positions(); const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`); await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, { button: 'middle', }); const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`); check( 'the middle button pans even when it starts on a node', Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 && Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3, JSON.stringify({ axisScreenBefore, axisScreenPanned }), ); check( 'panning over a node leaves the node where it was', (await positions()) === before13i, await positions(), ); // Fit again: the pan just now pushed a node out of view, and a band takes only what it sees. await clickText('Fit'); // An empty spot: after a fit every node sits at least a margin away from the corner. const corner = { x: area.x + 6, y: area.y + 6 }; const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 }; const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`); await drag(corner.x, corner.y, corner.x + 60, corner.y + 40); const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`); check( 'dragging with the left button no longer pans', Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3, JSON.stringify({ axisBeforeBand, axisAfterBand }), ); // 6k. A rubber band across the whole canvas takes every node – the count comes from the model, // so this cannot agree with the canvas by sharing its rule. const nodeCount = (await asset('mover')).graph.nodes.length; await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const banded = await json('__gm.graph.selected()'); check( 'a rubber band selects every node it spans', banded.length === nodeCount, `${banded.length} of ${nodeCount}`, ); await shot('21-graph-multiselect'); // 6l. The whole selection moves as one, and one undo takes all of it back. const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); const grabAt = await json(`__gm.graph.nodeAt('n-axis')`); // Screen pixels become graph units through the zoom, which the fit picks from the canvas size. const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()')); const [mx, my] = [await units(40), await units(30)]; await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30); const afterMove = await asset('mover'); const movedTogether = beforeMove.every((n) => { const now = afterMove.graph.nodes.find((m) => m.id === n.id); return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2; }); check('dragging one of them moves the whole selection', movedTogether, await positions()); await undo(); check('one undo takes the group move back', (await positions()) === before13i, await positions()); // 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the // current zoom – the canvas is not wide enough here for a zoom of 1. const GRID = 24; const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false); // The switch is a magnet (#194): a square icon button that stays lit while snapping is on. const magnet = await json('__gm.graph.snapButton()'); await click(magnet.x, magnet.y); check( 'the toggle switches it on without moving a single node', (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, await positions(), ); const magnetOn = await json('__gm.graph.snapButton()'); check( 'the magnet is a square icon without a caption, and lit while on', magnetOn.toggled && magnetOn.w === magnetOn.h && (await json(`__gm.locate('Snap')`)) === null, JSON.stringify(magnetOn), ); // A single node: dropped between two grid lines, it lands on the nearer one. await click(corner.x, corner.y); const snapPre = await nodeOf('n-axis'); const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); const snapBy = await units(50); await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50); const snapped = await nodeOf('n-axis'); check( 'a dragged node lands on the grid, not where the pointer let go', onGrid(snapped) && Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 && Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2, `${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`, ); const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis'); check( 'the nodes it was dragged past stay off the grid', others.some((n) => !onGrid(n)), JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)), ); // A group: every selected node snaps for itself, so the whole selection ends up on grid lines // – off-grid gaps inside it close, rather than travelling along. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); check( 'the selection is not on the grid yet', groupPre.some((n) => !onGrid(n)), JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)), ); const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`); const groupBy = await units(30); await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30); const groupPost = (await asset('mover')).graph.nodes; check( 'every selected node lines up on the grid for itself, none by more than half a step', groupPost.every(onGrid) && groupPre.every((n) => { const now = groupPost.find((m) => m.id === n.id); return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2; }), await positions(), ); await undo(); await undo(); check('undo takes both snapped moves back', (await positions()) === before13i, await positions()); // Switching it off has to give the pointer its exact drop position back. await click(magnet.x, magnet.y); await click(corner.x, corner.y); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); const offBy = await units(50); await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50); const offPost = await nodeOf('n-axis'); check( 'switched off, a node lands where it was dropped again, off the grid', (await json('__gm.graph.snapping()')) === false && !onGrid(offPost) && Math.abs(offPost.x - (offPre.x + offBy)) <= 2 && Math.abs(offPost.y - (offPre.y + offBy)) <= 2, `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, ); await undo(); // 6m. Delete acts on the selection, not just the primary node. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const beforeDelete = await graphCounts(); await press('Delete'); const afterDelete = await graphCounts(); check( 'Delete removes every selected node, edges with them', afterDelete.nodes === 0 && afterDelete.edges === 0, JSON.stringify(afterDelete), ); await undo(); const restoredAll = await graphCounts(); check( 'one undo brings the whole graph back', restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges, JSON.stringify(restoredAll), ); // 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen // and a click cannot miss one that the earlier pan pushed over the edge. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const axisRow = await json(`__gm.graph.nodeAt('n-axis')`); await click(axisRow.x, axisRow.y); const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis'); const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`); await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); check( 'ctrl-click adds a second node to the selection', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]), JSON.stringify(await json('__gm.graph.selected()')), ); await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); check( 'ctrl-clicking it again removes it', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']), JSON.stringify(await json('__gm.graph.selected()')), ); // 6o. Fit really fits: proven against a node parked far outside, and against the fact that the // graph is then wider than the canvas – otherwise centring alone would pass this check. // The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once // the canvas is short, and then Fit cannot fit anything however correct it is. const park = Math.round(area.w); await evaluate( `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`, ); await sleep(250); await clickText('Fit'); const rects = []; for (const node of (await asset('mover')).graph.nodes) rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x)); check( 'the parked node makes the graph wider than the canvas, so fitting has to zoom out', span > area.w * 0.5, `span=${Math.round(span)} canvas=${Math.round(area.w)}`, ); const outside = rects.filter( (r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h, ); check( 'Fit leaves no node hanging out of the view', outside.length === 0, `${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`, ); await shot('22-graph-fitted'); await evaluate( `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`, ); await clickText('Fit'); check('the parked node is back where it started', (await positions()) === before13i, await positions()); // 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and // comes back while either of its two nodes is selected. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const allEdges = (await asset('mover')).graph.edges; const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0]; const id = JSON.stringify(edge.id); const drawn = async () => await json(`__gm.graph.edgeDrawn(${id})`); const edgeNow = async () => (await asset('mover')).graph.edges.find((e) => e.id === edge.id); check('the link is drawn as a curve to start with', (await drawn()) === true); const outPort = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(outPort.x, outPort.y, 'right'); check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`))); await clickText('Hide link'); await select({ kind: 'asset', assetId: 'mover' }); const hidden = await edgeNow(); check( 'hiding changes nothing but a flag – the connection is still there', Boolean(hidden) && hidden.folded === true && hidden.from.node === edge.from.node && hidden.to.node === edge.to.node, JSON.stringify(hidden), ); check('and the curve is gone from the canvas', (await drawn()) === false); const mark = await json(`__gm.graph.foldMark(${id}, true)`); check( 'a stub with a mark takes its place at the port', mark !== null && Math.abs(mark.x - outPort.x) < 40 && Math.abs(mark.y - outPort.y) < 8, JSON.stringify({ mark, outPort }), ); await shot('23-link-hidden'); // Selecting either end lends the curve back – the source first, then the target. const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`); await click(sourceAt.x, sourceAt.y); check('selecting the node at one end brings the curve back', (await drawn()) === true); const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`); await click(targetAt.x, targetAt.y); check('selecting the node at the other end does too', (await drawn()) === true); await select({ kind: 'asset', assetId: 'mover' }); check('deselecting hides it again', (await drawn()) === false); // The mark is the handle: clicking it selects the node at the far end, which is also what // puts the curve back – so it answers "where does this go?" in one click. const mark2 = await json(`__gm.graph.foldMark(${id}, true)`); await click(mark2.x, mark2.y); check( 'clicking the mark jumps to the node at the far end', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]), JSON.stringify(await json('__gm.graph.selected()')), ); check('which shows the curve again', (await drawn()) === true); await undo(); await select({ kind: 'asset', assetId: 'mover' }); const shown = await edgeNow(); check( 'undo unhides the link, with nothing left behind', shown.folded === undefined && (await drawn()) === true, JSON.stringify(shown), ); // 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on // the very same pixels – the names smearing over each other. One port, one mark. const before13p = await graphCounts(); const forkPort = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); // A spot no node covers, measured rather than guessed: after the Fit above, where the nodes // sit on screen is not something this step gets to assume. const canvasArea = await json('__gm.graph.canvasRect()'); const taken = []; for (const node of (await asset('mover')).graph.nodes) taken.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); const freeSpots = [ { x: canvasArea.x + 60, y: canvasArea.y + canvasArea.h - 60 }, { x: canvasArea.x + canvasArea.w - 60, y: canvasArea.y + canvasArea.h - 60 }, { x: canvasArea.x + 60, y: canvasArea.y + 60 }, ]; const free = freeSpots.find((p) => taken.every( (r) => !r || p.x < r.x - 10 || p.y < r.y - 10 || p.x > r.x + r.w + 10 || p.y > r.y + r.h + 10, ), ) ?? freeSpots[0]; // The same gesture as 13d3: dropped into empty space the palette offers only what this port can // reach, and the picked node arrives already wired – so no port name has to be guessed here. await mouse('mouseMoved', forkPort.x, forkPort.y); await mouse('mousePressed', forkPort.x, forkPort.y); await mouse('mouseMoved', free.x, free.y, 'left', { buttons: 1 }); await sleep(50); await mouse('mouseReleased', free.x, free.y); await sleep(200); const forkRow = await locate('Clamp'); if (forkRow) await click(forkRow.x, forkRow.y); await sleep(250); const twin = (await state()).selection?.nodeId; const forked = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'a second link now leaves the same port', forked.length === 2 && (await graphCounts()).nodes === before13p.nodes + 1, JSON.stringify({ forked: forked.length, twin, free, row: Boolean(forkRow) }), ); await click(forkPort.x, forkPort.y, 'right'); check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`))); await clickText('Hide 2 links'); await select({ kind: 'asset', assetId: 'mover' }); const ids = forked.map((e) => JSON.stringify(e.id)); const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`); const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`); check( 'both hidden links share one mark at the port instead of stacking two', markA !== null && markA.x === markB.x && markA.y === markB.y && markA.links === 2, JSON.stringify({ markA, markB }), ); const marks = await json(`__gm.graph.foldMarks()`); check( 'and only three marks are drawn for the two links: one shared, one at each far end', marks.length === 3 && marks.filter((m) => m.links === 2).length === 1, JSON.stringify(marks), ); check( 'the shared mark names one far node and counts the rest', markA.label.endsWith('+1') && markA.label.length > 2, JSON.stringify(markA.label), ); await shot('24-links-bundled'); // The bundled mark is still a handle: one click selects every far end, which is also what // lends all of its curves back at once. await click(markA.x, markA.y); const picked = await json('__gm.graph.selected()'); check( 'clicking a bundled mark selects every node the port feeds', picked.length === 2 && picked.includes(edge.to.node) && picked.includes(twin), JSON.stringify(picked), ); check( 'and both curves come back at once', (await json(`__gm.graph.edgeDrawn(${ids[0]})`)) === true && (await json(`__gm.graph.edgeDrawn(${ids[1]})`)) === true, ); await click(markA.x, markA.y, 'right'); check( 'the mark menu shows both links back in one go', Boolean(await evaluate(`__gm.locate('Show 2 links')`)), ); await clickText('Show 2 links'); await select({ kind: 'asset', assetId: 'mover' }); const unfolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'showing them again leaves no fold behind', unfolded.every((e) => e.folded === undefined) && (await json(`__gm.graph.foldMarks()`)).length === 0, JSON.stringify(unfolded), ); // One step of undo takes both back – hiding two links was one command, so it is one undo. await undo(); await select({ kind: 'asset', assetId: 'mover' }); const refolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'and undo re-hides both as one step', refolded.length === 2 && refolded.every((e) => e.folded === true), JSON.stringify(refolded.map((e) => e.folded)), ); await undo(); const forkedNow = async () => (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ).length; const portNow = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(portNow.x, portNow.y, 'right'); const offered = await find('Disconnect 2 links'); if (offered) await click(offered.x, offered.y); await sleep(250); const cut = await forkedNow(); await undo(); check( 'disconnecting both links at a port is one undo step', Boolean(offered) && cut === 0 && (await forkedNow()) === 2, JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }), ); // 6r. Collapsing a node: the title bar alone, every link meeting at one point per side. No new // link lands on it until it is expanded, and the state is saved with the project. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const edgesBefore13q = (await graphCounts()).edges; const openRect = await json(`__gm.graph.nodeRect('n-move')`); const chevron = await json(`__gm.graph.chevronAt('n-move')`); await click(chevron.x, chevron.y); const shut = await json(`__gm.graph.nodeRect('n-move')`); const zoom = openRect.w / 176; check( 'the chevron collapses the node to its title bar, and leaves the selection alone', (await nodeOf('n-move')).collapsed === true && Math.abs(shut.h - 24 * zoom) < 1 && (await json('__gm.graph.selected()')).length === 0, JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }), ); // Title starts 20 units in and ends 11 before the edge; `locate` matches exactly, so a cut title is null. const titleText = await json(`__gm.locate('Move')`); check( 'a collapsed node narrows to its whole title, keeping its left edge under the chevron', titleText !== null && Math.abs(shut.x - openRect.x) < 1 && shut.w < openRect.w && Math.abs(shut.w - (2 * (titleText.x - shut.x) - 9 * zoom)) < 2, JSON.stringify({ openRect, shut, titleText }), ); const title = await json(`__gm.graph.nodeAt('n-move')`); const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`); const inputs = [ await anchorOf('exec', false), await anchorOf('object', false), await anchorOf('dy', false), ]; const output = await anchorOf('exec', true); check( 'every link meets at one point per side, level with the title', inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) && Math.abs(output.x - (shut.x + shut.w)) < 1 && Math.abs(output.y - title.y) < 1, JSON.stringify({ inputs, output, title, shut }), ); check( 'the links are still there and still drawn', (await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true, ); await shot('24b-node-collapsed'); const speedOut = await json(`__gm.graph.portAt('n-speed', 'speed', true)`); await mouse('mouseMoved', speedOut.x, speedOut.y); await mouse('mousePressed', speedOut.x, speedOut.y); await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 }); await sleep(50); const aimed = await json('__gm.graph.dropTarget()'); await mouse('mouseReleased', title.x, title.y); await sleep(150); await press('Escape'); check( 'a link dropped on a collapsed node goes nowhere', aimed === null && (await graphCounts()).edges === edgesBefore13q, JSON.stringify(aimed), ); for (const port of ['x', 'y']) { const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`); await click(at.x, at.y, 'right'); await clickText('Hide link'); } await select({ kind: 'asset', assetId: 'mover' }); const markDx = await json(`__gm.graph.foldMark('e3', false)`); const markDy = await json(`__gm.graph.foldMark('e4', false)`); check( 'hidden links into a collapsed node share one mark instead of stacking', markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2, JSON.stringify({ markDx, markDy }), ); await undo(); await undo(); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); await click(title.x, title.y, 'right'); check( 'a mixed selection is offered both ways', Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) && Boolean(await evaluate(`__gm.locate('Expand node', false)`)), ); await press('Escape'); await undo(); check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); const openTitle = await json(`__gm.graph.nodeAt('n-move')`); await click(openTitle.x, openTitle.y, 'right'); const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`); check('two expanded nodes are offered as one', collapseBoth !== null); if (collapseBoth) await click(collapseBoth.x, collapseBoth.y); else await press('Escape'); await sleep(200); const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed]; check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]'); await undo(); check( 'and one undo takes all of it back', JSON.stringify(await both()) === '[null,null]', JSON.stringify(await both()), ); // Saved and loaded: a reload, not just the file, because the drawing is what has to come back. await select({ kind: 'asset', assetId: 'mover' }); const again = await json(`__gm.graph.chevronAt('n-move')`); await click(again.x, again.y); const savedMove = async () => { await saved(); const doc = await api(`projects/${projectId}`); return doc.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); }; check('the collapse reaches the saved file', (await savedMove()).collapsed === true); await navigate(); await sleep(500); await select({ kind: 'asset', assetId: 'mover' }); await showTab('graph'); const reloaded = await json(`__gm.graph.nodeRect('n-move')`); check( 'and a reloaded project draws it collapsed again', (await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - shut.h / shut.w) < 0.01, JSON.stringify(reloaded), ); const reopen = await json(`__gm.graph.chevronAt('n-move')`); await click(reopen.x, reopen.y); check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); // 6z. Ports carrying one value (#207): the output takes the type both inputs agree on, the // inputs stay open for everything, and an edge the new type breaks is drawn as broken. await mutate(` const p = __gm.state.project; p.assets.push({ id: 'picky', type: 'behavior', name: 'Picky', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [ { id: 'p-a', type: 'Number', x: 0, y: 0, params: { value: 1 } }, { id: 'p-b', type: 'Number', x: 0, y: 150, params: { value: 2 } }, { id: 'p-text', type: 'String', x: 0, y: 300, params: { value: 'hi' } }, { id: 'p-pick', type: 'FirstNotEmpty', x: 300, y: 60, params: {} }, { id: 'p-sub', type: 'Substring', x: 600, y: 60, params: {} }, ], edges: [], }, }); __gm.state.select({ kind: 'asset', assetId: 'picky' }); `); await showTab('graph'); const pickTypes = async () => json(`__gm.graph.portTypes('p-pick')`); check( 'a group with nothing plugged in stays any', (await pickTypes()).outputs.value === 'any', JSON.stringify(await pickTypes()), ); const wire = async (fromNode, fromPort, toNode, toPort) => { const from = await json( `__gm.graph.portAt(${JSON.stringify(fromNode)}, ${JSON.stringify(fromPort)}, true)`, ); const to = await json(`__gm.graph.portAt(${JSON.stringify(toNode)}, ${JSON.stringify(toPort)}, false)`); await drag(from.x, from.y, to.x, to.y, 16); }; // Laid while the output is still any, so a number later makes it a text input fed by a number. await wire('p-pick', 'value', 'p-sub', 'text'); const toSub = async () => (await asset('picky')).graph.edges.find((e) => e.to.node === 'p-sub'); const outEdge = await toSub(); const edgeColor = async () => json(`__gm.graph.edgeColor(${JSON.stringify(outEdge?.id ?? '')})`); const danger = await json(`__gm.themeColor('danger')`); check( 'an any output connects to a text input and is drawn as a working link', outEdge !== undefined && (await edgeColor()) !== danger, JSON.stringify({ edge: Boolean(outEdge), color: await edgeColor(), danger }), ); await wire('p-a', 'value', 'p-pick', 'a'); check( 'one input alone says nothing: the other could still become anything', (await pickTypes()).outputs.value === 'any', JSON.stringify(await pickTypes()), ); await wire('p-b', 'value', 'p-pick', 'b'); const typed = await pickTypes(); check( 'with both inputs on a number the output is a number, the inputs stay any', typed.outputs.value === 'number' && typed.inputs.a === 'any' && typed.inputs.b === 'any', JSON.stringify(typed), ); check( 'both input pins are painted as the number that flows in, though their type is any', typed.paint.a === 'number' && typed.paint.b === 'number', JSON.stringify(typed), ); check( 'the edge the new type breaks stays and is drawn in the danger colour', (await toSub()) !== undefined && (await edgeColor()) === danger, JSON.stringify({ color: await edgeColor(), danger }), ); await shot('19-shared-port-types'); // An input that keeps its `any` is one that can be re-plugged without unplugging first. await wire('p-text', 'value', 'p-pick', 'a'); const mixed = await pickTypes(); check( 'a connected input takes another type, and the group opens up again', mixed.outputs.value === 'any' && (await edgeColor()) !== danger, JSON.stringify({ types: mixed, color: await edgeColor() }), ); check( 'inputs fed by different types are painted apart, which is why the output says nothing', mixed.paint.a === 'string' && mixed.paint.b === 'number' && mixed.inputs.a === 'any', JSON.stringify(mixed), ); // Read off the canvas: the colour is on the pin, not only in the definition. const pinPixel = async (port) => { const at = await json(`__gm.graph.portAt('p-pick', ${JSON.stringify(port)}, false)`); return json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`); }; const pinColors = { a: await pinPixel('a'), b: await pinPixel('b') }; const wanted = { a: await json(`__gm.themeColor('portString')`), b: await json(`__gm.themeColor('portNumber')`), }; check( 'the two pins are drawn in those two colours', pinColors.a === wanted.a && pinColors.b === wanted.b, JSON.stringify({ pinColors, wanted }), ); await shot('20-input-pin-colours'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'picky'); __gm.state.select({ kind: 'asset', assetId: 'mover' }); `); await showTab('scene'); }