The first version of #229 put a Keep state switch on the Run behavior node. The user wanted it where the variable is declared instead. Whether a value lives on belongs to the variable, not to every call site. - The node switch keepState is gone again. RunBehavior, CallResult and nested.ts are back to what they were before #229. - BehaviorAsset.keptVariables lists the kept names. It lives on the asset and not in PropertyDef, because that is the schema of every inspector row. - A variable added from now on starts with Keep on. Variables in existing projects start with Keep off, so old projects run unchanged. - SetVariableKeptCommand flips the switch as one undo step. Add, remove and rename carry the list along. Loading drops names that are not declared. - The interpreter keeps only the listed variables, per Run behavior node and per object as before. The others start at their defaults. - Local state machines in a called behavior start empty on every call again, as before #229. Global and scene machines are unchanged. - The inspector shows a Keep button in each variable row of a behavior. Its state is part of the asset signature, so a click redraws the row. Without that the button kept its old look until the inspector was reopened. Found: the project Garden of Sacrifices still has keepState = true on the call Player -> Character Movement. It is ignored now. Direction there predates the switch, so Keep is off until set by hand. Tests: - ./check.sh: 163 files, 2392 tests, CHECK EXIT 0 (before 2388) - tests/core/keep-state.test.ts rewritten, 15 tests - e2e 19: ALL PASSED, 35 checks. 19c-ii now clicks the Keep button of a new variable: on when added, off by a click, on again, off after undo. - Counter-checks, each fails its own test: keep list ignored (2), states kept (1), new variable not kept (8), rename drops the list (1), no Keep button (e2e 19c-ii). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
18 KiB
JavaScript
406 lines
18 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 kept, a click on its Keep button lets it go, and undo keeps it 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();
|
||
const keepButton = await locate('Keep');
|
||
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: on when added, off by a click, on by the next, off after undo',
|
||
!nodeKeys.includes('keepState') &&
|
||
keptAdded === '["var1"]' &&
|
||
Boolean(keepButton) &&
|
||
keptClicked === '[]' &&
|
||
keptReclicked === '["var1"]' &&
|
||
keptUndone === '[]' &&
|
||
(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)}`,
|
||
);
|
||
|
||
// 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 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),
|
||
);
|
||
}
|