import { edge, node, port } from '../lib/builders.mjs'; import { clickDocumentTab, MAKE_OBJECT } from './lib/scaffold.mjs'; /** * A behavior used as a node inside another behavior's graph: the ports it grows from the other * graph's start/end nodes, the call itself while playing, and declaring a port across the two. * * The step brings its own box and its own two behaviors; everything it adds is removed at the end. */ const SCALE = 100; /** `in1` → ×2 → `out1`, entered at the start node and left at the end node. */ const doubleGraph = { nodes: [ node('nb-start', 'BehaviorStart', 0, 0, {}, [port('in1', 'number')]), node('nb-times', 'Multiply', 240, 60, { b: 2 }), node('nb-end', 'BehaviorEnd', 520, 0, {}, [port('out1', 'number')]), ], edges: [ edge('nb-e1', 'nb-start', 'exec', 'nb-end', 'exec'), edge('nb-e2', 'nb-start', 'in1', 'nb-times', 'a'), edge('nb-e3', 'nb-times', 'result', 'nb-end', 'out1'), ], }; /** * `On update → Run(Double, in1 = 1) → Move(owner, dx = out1)`. The caller does no arithmetic of * its own: the speed it moves at *is* the value the nested behavior handed back. */ const callerGraph = { nodes: [ node('nb-update', 'OnUpdate', 0, 0), node('nb-owner', 'Owner', 0, 160), node('nb-call', 'RunBehavior', 260, 0, { behavior: 'nb-double', in1: 1 }), node('nb-move', 'Move', 560, 0, { scale: SCALE }), ], edges: [ edge('nb-c1', 'nb-update', 'exec', 'nb-call', 'exec'), edge('nb-c2', 'nb-call', 'exec', 'nb-move', 'exec'), edge('nb-c3', 'nb-call', 'out1', 'nb-move', 'dx'), edge('nb-c4', 'nb-update', 'dt', 'nb-move', 'dt'), edge('nb-c5', 'nb-owner', 'object', 'nb-move', 'object'), ], }; export async function nestedBehavior(t) { const { check, sleep, evaluate, json, shot, click, clickText, typeText, locate, press, undo, object, entity, asset, mutate, stop, } = t; const portAt = (nodeId, key, isOutput) => json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); const startPorts = async () => (await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-start').ports ?? []; // 19a. Two behaviors and a box wearing the calling one. Written into the model directly: what // is under test is the call, not another way of drawing a graph. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const mover = p.assets.find((a) => a.id === 'mover'); // At the front of the list, not the end: these get dragged out of the asset panel below, and // by this point in the suite the list is long enough to scroll a row at the end out of reach. const behavior = (id, name, graph) => { const a = structuredClone(mover); Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph }); p.assets.unshift(a); }; behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)}); behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)}); behavior('nb-empty', 'Empty', { nodes: [], edges: [] }); ${MAKE_OBJECT} scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller')); `); // 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and // dx is nothing the caller holds – it is what the nested graph returned. Without the result // dx would be 0 and the box would not move at all. const boxX = async () => (await object('nb-box')).position.x; const idleX = await boxX(); await t.play(); const before = (await entity('nb-box')).x; await sleep(1000); const after = (await entity('nb-box')).x; const speed = after - before; check( 'a behavior used as a node runs, and what it returns is what drives the caller', speed > SCALE * 1.4 && speed < SCALE * 2.6, `moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`, ); await stop(); check( 'the stored object is untouched by the run, as in any play session', (await boxX()) === idleX, `x=${await boxX()}`, ); // 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of // its own type. await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); const inPort = await portAt('nb-call', 'in1', false); const outPort = await portAt('nb-call', 'out1', true); check( 'the calling node grows the ports the other behavior declares', Boolean(inPort) && Boolean(outPort) && outPort.x > inPort.x, JSON.stringify({ inPort, outPort }), ); const title = await evaluate(`__gm.graph.nodeLabel('nb-call')`); check( 'and it is drawn under that behavior\'s name, not as "Run behavior"', title === 'Double', `title=${JSON.stringify(title)}`, ); await shot('37-nested-behavior'); // 19c-ii. Keep (#229) is a switch on each variable of the called behavior, not on the node: a new // variable starts unkept, a click on its Keep button keeps it, and undo lets it go again. const kept = async () => JSON.stringify((await asset('nb-double')).keptVariables ?? []); const nodeKeys = Object.keys((await asset('nb-caller')).graph.nodes.find((n) => n.id === 'nb-call').params); await t.select({ kind: 'asset', assetId: 'nb-double' }); await clickText('+ Variable'); await clickText('Number'); const keptAdded = await kept(); // A pin since #257: a list's head has no room for the word beside the lock. const keepButton = ((await t.json("__gm.locateIcons('keep')")) ?? [])[0] ?? null; if (keepButton) await click(keepButton.x, keepButton.y); const keptClicked = await kept(); // A second click has to flip it back: a button drawn for the old switch would clear it again. if (keepButton) await click(keepButton.x, keepButton.y); const keptReclicked = await kept(); await undo(); const keptUndone = await kept(); await undo(); await undo(); check( 'a variable has a Keep switch: off when added, on by a click, off by the next, on after undo', !nodeKeys.includes('keepState') && keptAdded === '[]' && Boolean(keepButton) && keptClicked === '["var1"]' && keptReclicked === '[]' && keptUndone === '["var1"]' && (await kept()) === '[]', `node=${nodeKeys} added=${keptAdded} button=${JSON.stringify(keepButton)} clicked=${keptClicked} reclicked=${keptReclicked} undone=${keptUndone} after=${await kept()}`, ); // Back to the call, which is where 19d looks. await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the // check that a port list derived from another graph is really derived and not copied. check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false))); await t.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] }); const addButton = await locate('+ Input'); check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton)); await shot('38-behavior-ports'); await click(addButton.x, addButton.y); await clickText('Number'); const declared = await startPorts(); check( 'adding an input declares it on the start node', declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number', JSON.stringify(declared), ); await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); check( 'and the node in the other graph grew that input without being touched', Boolean(await portAt('nb-call', 'in2', false)), ); // 19e. Undo takes the port back, and with it the port on the calling node. await undo(); const afterUndo = await startPorts(); check( 'undo removes it in both graphs', afterUndo.length === 1 && !(await portAt('nb-call', 'in2', false)), `${JSON.stringify(afterUndo)}`, ); // 19e-ii. Further flow ports and `On behavior` (#294). The end node declares a second way out // with the mouse, the call grows the pin, and takes it once the end is reached by it. // "Confirm" is a behavior heard as an event: Enter or Space inside, one node outside. const WAY = 'left by out2'; const HEARD = 'confirm heard'; const endPorts = async () => (await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-end').ports ?? []; await t.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-end', nodeIds: ['nb-end'] }); await clickText('+ Output'); await clickText('Flow'); const ways = await endPorts(); await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); const wayPin = await portAt('nb-call', 'out2', true); const valuePin = await portAt('nb-call', 'out1', true); check( 'a flow port declared at the end node becomes a flow output of the call, above its values', ways.length === 2 && ways[1].key === 'out2' && ways[1].kind === 'flow' && Boolean(wayPin) && wayPin.y < valuePin.y, `${JSON.stringify(ways)} pin=${JSON.stringify(wayPin)} value=${JSON.stringify(valuePin)}`, ); await mutate(` const p = __gm.state.project; const find = (id) => p.assets.find((a) => a.id === id); const confirm = structuredClone(find('nb-empty')); Object.assign(confirm, { id: 'nb-confirm', name: 'Confirm', graph: ${JSON.stringify({ nodes: [ node('nb-k1', 'OnKeyDown', 0, 0, { key: 'Enter' }), node('nb-k2', 'OnKeyDown', 0, 200, { key: ' ' }), node('nb-cend', 'BehaviorEnd', 360, 80, {}, [port('pressed', 'flow')]), ], edges: [ edge('nb-k1e', 'nb-k1', 'exec', 'nb-cend', 'pressed'), edge('nb-k2e', 'nb-k2', 'exec', 'nb-cend', 'pressed'), ], })} }); p.assets.unshift(confirm); find('nb-double').graph.edges.push(${JSON.stringify(edge('nb-way', 'nb-start', 'exec', 'nb-end', 'out2'))}); const caller = find('nb-caller').graph; caller.nodes.push( ${JSON.stringify(node('nb-way-log', 'Log', 560, 260, { message: WAY }))}, ${JSON.stringify(node('nb-on', 'OnBehavior', 0, 420, { behavior: 'nb-confirm' }))}, ${JSON.stringify(node('nb-on-log', 'Log', 340, 420, { message: HEARD }))}, ); caller.edges.push( ${JSON.stringify(edge('nb-w1', 'nb-call', 'out2', 'nb-way-log', 'exec'))}, ${JSON.stringify(edge('nb-w2', 'nb-on', 'pressed', 'nb-on-log', 'exec'))}, ); `); await clickText('Fit'); const heardTitle = await evaluate(`__gm.graph.nodeLabel('nb-on')`); const heardPin = await portAt('nb-on', 'pressed', true); check( 'a behavior heard as an event stands in the graph as "On " with the flow ports of its end', heardTitle === 'On Confirm' && Boolean(heardPin) && (await evaluate(`__gm.graph.nodeProblem('nb-on')`)) === null, `title=${JSON.stringify(heardTitle)} pin=${JSON.stringify(heardPin)}`, ); await shot('38b-flow-ports'); const lines = async (text) => (await t.world()).log.filter((line) => line.includes(text)).length; await t.play(); await sleep(400); const quiet = { way: await lines(WAY), heard: await lines(HEARD) }; await t.holdKey('Enter', 120); await sleep(200); const afterEnter = await lines(HEARD); await t.holdKey('Space', 120); await sleep(200); const afterSpace = await lines(HEARD); await sleep(300); const afterWait = await lines(HEARD); const speedBoth = (await entity('nb-box')).x; await stop(); check( 'a call leaves through the flow port its end node was reached by, and still goes on along exec', quiet.way > 5 && speedBoth > idleX + 20, `"${WAY}" ${quiet.way} times in 0.4 s, box at ${speedBoth.toFixed(1)} from ${idleX}`, ); check( 'On behavior fires once for each key the behavior it hears listens to, and not in between', quiet.heard === 0 && afterEnter === 1 && afterSpace === 2 && afterWait === 2, `idle=${quiet.heard} Enter=${afterEnter} Space=${afterSpace} later=${afterWait}`, ); await mutate(` const p = __gm.state.project; const find = (id) => p.assets.find((a) => a.id === id); p.assets = p.assets.filter((a) => a.id !== 'nb-confirm'); const double = find('nb-double').graph; double.edges = double.edges.filter((e) => e.id !== 'nb-way'); const end = double.nodes.find((n) => n.id === 'nb-end'); end.ports = end.ports.filter((port) => port.key !== 'out2'); const caller = find('nb-caller').graph; caller.nodes = caller.nodes.filter((n) => !['nb-way-log', 'nb-on', 'nb-on-log'].includes(n.id)); caller.edges = caller.edges.filter((e) => !['nb-w1', 'nb-w2'].includes(e.id)); `); // Stopping leaves the game tab in front; what follows clicks in the graph. await t.showTab('graph'); await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); await clickText('Fit'); // 19f. The palette offers a behavior under its own name, and picking it leaves a node that // already names it – one pick, not a pick plus a dropdown. ("Double" also labels the asset // in the list on the left, so the proof is the node that appears, not the row.) const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id); const idsBefore = await nodeIds(); const bare = await json('__gm.graph.emptySpot()'); await click(bare.x, bare.y, 'right'); check('node palette opens', Boolean(await locate('Search nodes…'))); await typeText('Double'); // Enter takes the first match. NOT a click on the label: "Double" also names the asset in the // list on the left, and that is what a click by text would find first. await press('Enter'); const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id)); check( 'picking a behavior in the palette drops in a node already set to it', (await nodeIds()).length === idsBefore.length + 1 && added?.type === 'RunBehavior' && added?.params.behavior === 'nb-double', JSON.stringify(added && { type: added.type, params: added.params }), ); await undo(); // 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the // pointer let go of it. const newNodeIn = async (before) => (await asset('nb-caller')).graph.nodes.find((n) => !before.includes(n.id)); const beforeDrag = await nodeIds(); // Points taken from the drawing area itself: an offset from the centre can fall outside it, // and a drop that never arrives looks exactly like one that was refused. const rect = await json('__gm.graph.canvasRect()'); const spot = (fx, fy) => ({ x: Math.round(rect.x + rect.w * fx), y: Math.round(rect.y + rect.h * fy), }); const dropAt = spot(0.25, 0.75); await t.dragAsset('nb-double', dropAt.x, dropAt.y); const dropped = await newNodeIn(beforeDrag); check( 'a behavior dragged from the asset panel becomes a call node', dropped?.type === 'RunBehavior' && dropped?.params.behavior === 'nb-double', JSON.stringify(dropped && { type: dropped.type, params: dropped.params }), ); const droppedAt = dropped && (await json(`__gm.graph.nodeAt(${JSON.stringify(dropped.id)})`)); check( 'and it lands under the pointer, not at some default spot', Boolean(droppedAt) && Math.hypot(droppedAt.x - dropAt.x, droppedAt.y - dropAt.y) < 20, `dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`, ); // 19h. A behavior with no start node can still be added, but says why nothing happens. const beforeEmpty = await nodeIds(); const emptyRow = await json(`__gm.assetRow('nb-empty')`); await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y); const emptyCall = await newNodeIn(beforeEmpty); check( 'a behavior without a start node can be added just the same', emptyCall?.params.behavior === 'nb-empty', `${JSON.stringify(emptyCall && { type: emptyCall.type, params: emptyCall.params })} row=${JSON.stringify(emptyRow)}`, ); const problem = await evaluate(`__gm.graph.nodeProblem(${JSON.stringify(emptyCall?.id ?? '')})`); check( 'and the node says why it does nothing, instead of failing silently', problem === 'No start or On start node – does nothing', `problem=${JSON.stringify(problem)}`, ); check( 'a healthy call carries no such warning', (await evaluate(`__gm.graph.nodeProblem('nb-call')`)) === null, ); await shot('39-call-problem'); // 19i. Dropping a behavior into its own graph is refused – and says so, rather than quietly // adding a node that would throw the moment it ran. const beforeSelf = await nodeIds(); const selfRow = await json(`__gm.assetRow('nb-caller')`); await t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y); const afterSelf = await nodeIds(); const { notice } = await t.state(); // `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone. const dragging = await json('__gm.dragPayload()'); check( 'dropping a behavior into its own graph is refused, out loud', afterSelf.length === beforeSelf.length && notice === 'A behavior cannot run itself', `nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`, ); // 19j. Double-clicking a call opens the behavior it runs. Double calls Empty in turn, so the // breadcrumb below gets a third level. The call sits far down: only a graph tall enough to // fill the canvas shows whether it was fitted to the canvas before or after the bar. await mutate(` const double = __gm.state.project.assets.find((a) => a.id === 'nb-double'); double.graph.nodes.push(${JSON.stringify(node('nb-call2', 'RunBehavior', 240, 900, { behavior: 'nb-empty' }))}); `); const callNode = await json(`__gm.graph.nodeAt('nb-call')`); check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller'); check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0); await t.doubleClick(callNode.x, callNode.y); const opened = await evaluate('__gm.graph.current()'); check( 'double-clicking a call node opens the behavior it runs', opened === 'nb-double', `current=${JSON.stringify(opened)}`, ); // 19k. The breadcrumb: every behavior entered by double-click, each earlier one a way back. const crumbs = () => json('__gm.graph.crumbs()'); const trail = async () => (await crumbs()).map((c) => `${c.name}${c.clickable ? '' : '*'}`).join(' › '); check('opening a call shows the way back', (await trail()) === 'Caller › Double*', await trail()); const cut = await json(`(() => { const c = __gm.graph.canvasRect(); return __gm.state.project.assets.find((a) => a.id === 'nb-double').graph.nodes .map((n) => ({ id: n.id, r: __gm.graph.nodeRect(n.id) })) .filter(({ r }) => r.y < c.y || r.y + r.h > c.y + c.h) .map(({ id }) => id); })()`); check('the graph opened is fitted below the bar, not under it', cut.length === 0, `cut off: ${cut}`); const inner = await json(`__gm.graph.nodeAt('nb-call2')`); await click(inner.x, inner.y); // Past the double-click window: the pair below must count as its own two presses. await sleep(500); check( 'selecting a node inside keeps the breadcrumb', (await trail()) === 'Caller › Double*', await trail(), ); await t.doubleClick(inner.x, inner.y); check( 'each call opened further down adds a crumb', (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*', `current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`, ); // A width of its own too: crumbs never laid out are all 0 wide at one spot, and overlap nothing. const overlaps = (await crumbs()).filter( (c, i, all) => c.right - c.left < 10 || (i > 0 && c.left < all[i - 1].right), ); check( 'the crumbs sit side by side, not on top of each other', overlaps.length === 0, JSON.stringify((await crumbs()).map((c) => [c.name, Math.round(c.left), Math.round(c.right)])), ); await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Twice'`); check('a renamed behavior renames its crumb', (await trail()) === 'Caller › Twice › Empty*', await trail()); await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Double'`); await shot('39b-breadcrumb'); const clickCrumb = async (name) => { const crumb = (await crumbs()).find((c) => c.name === name); await click(crumb.x, crumb.y); }; await clickCrumb('Double'); await shot('39c-breadcrumb-back'); check( 'a crumb leads back, and cuts the trail there', (await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*', `current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`, ); await clickCrumb('Caller'); check( 'back at the root, the breadcrumb is gone', (await evaluate('__gm.graph.current()')) === 'nb-caller' && (await crumbs()).length === 0, `current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`, ); // Another behavior opened any other way gets a tab of its own (#171), without a trail; the trail // stays with the tab it was made in, and comes back with it. const enterDouble = async () => { const call = await json(`__gm.graph.nodeAt('nb-call')`); await t.doubleClick(call.x, call.y); }; await enterDouble(); const followed = await json('__gm.documents()'); check( 'following a call stays in the same tab', followed.active === 'asset:nb-double' && !followed.docs.includes('asset:nb-caller'), JSON.stringify(followed), ); const emptyAgain = await json(`__gm.assetRow('nb-empty')`); await t.doubleClick(emptyAgain.x, emptyAgain.y); check( 'another behavior opened from the asset panel comes up in its own tab, without a trail', (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0 && (await json('__gm.documents()')).docs.includes('asset:nb-double'), `current=${await evaluate('__gm.graph.current()')} trail=${await trail()} docs=${JSON.stringify(await json('__gm.documents()'))}`, ); await clickDocumentTab(t, 'asset:nb-double'); check( 'back in the tab the call was followed in, its trail is there again', (await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*', `current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`, ); await evaluate(`__gm.closeDocument('asset:nb-empty')`); await evaluate(`__gm.closeDocument('asset:nb-double')`); // 19l. Clean up: the three behaviors and the box are gone again. await evaluate(`__gm.state.select(null)`); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const mine = ['nb-double', 'nb-caller', 'nb-empty']; p.assets = p.assets.filter((a) => !mine.includes(a.id)); scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); `); const left = await json(`{ objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length, assets: __gm.state.project.assets.filter((a) => a.id.startsWith('nb-')).length, }`); check( 'the step leaves nothing of its own behind', left.objects === 0 && left.assets === 0, JSON.stringify(left), ); }