A behavior run by another one had a single flow port at its Start and at its End, so it could not branch for its caller. And it could not be used as an event: a call needs a pulse at its flow input, and the events of a called graph never fire. An input like "Confirm" (Space or Enter) had to be built again in every graph. - A declared port of a Start or an End node can be of the kind Flow. It sits above the values and shares their names, exec stays reserved. Only these two nodes declare one, a struct does not. - Run behavior shows a flow port of the Start as a further flow input. A call arriving through it leaves the Start through the output of that name and not through exec. - Run behavior shows a flow port of the End as a further flow output. The call goes on through the one the End was reached by last, and then through exec. exec fires always, as before. - On behavior is a new event node without a flow input. The behavior it names runs at the node for as long as the behavior around it runs, with all of its own events and one set of variables per node. - Its outputs are those of the End. It fires once the inner pulse is through, by the output the End was reached by last, exec only for End.exec. A pulse that never reaches the End is not seen. - The value ports of the Start are its inputs, read anew for each inner pulse. The Start itself never fires there. - It nests to any depth. Under a Run behavior it stays silent. A behavior hearing itself, however far round, is not started and the node says "A behavior cannot listen to itself". - The palette offers every other behavior with an End once more as "On <name>". A node naming one without End says so. - An On node removed while the game runs ends its behavior with On disable and On destroy. The sounds of the entity end only with the behavior placed on it. - Renaming and deleting a declared port follows the edges at On behavior nodes as it does at Run behavior nodes. - The flow outputs of the Start of a behavior placed on an object never fire, like its exec. - The engine prompt of the benchmark names flow ports and On behavior. Tests: - Gate on melektaus-atx right before this commit: 215 files, 3319 tests (3278 + 41), exit 0. - e2e 19 on melektaus-atx: 39 checks (+4), all pass. e2e 1-6,17-24,26,36,51: 628 checks, all pass. - 19 unit tests in flow-ports.test.ts, 19 in on-behavior.test.ts on a real world, the rest in the node catalog. - Four new e2e checks in module 19: a flow port declared by mouse at the End, On Confirm in the graph, the call leaving through the further output while playing, Enter and Space on a real keyboard. - 19 unit counter-probes all fail their tests. One did not at first (a removed node kept its instance), a test was added. Two browser counter-probes fail their checks. - One screenshot looked at: On Confirm with exec and pressed, the second flow pin at the calling node. - The e2e sum of 1680 is counted (1676 + 4), the full suite and the smokes did not run. - Not seen in the browser: flow inputs at the Start, nesting, the ring, values through On behavior, the palette entry. No test holds that the sounds of the entity go on when a heard behavior ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
510 lines
23 KiB
JavaScript
510 lines
23 KiB
JavaScript
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 <name>" 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),
|
||
);
|
||
}
|