Files
feuerwerk/tools/e2e/23-variable-nodes.mjs
T
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:00:30 +02:00

231 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
* variables, so the step checks all three sides of that – the run, the picking of a port from the
* variable list, and pointing an existing port at another variable without losing its edge.
*
* The step brings its own box and behavior; everything it adds is removed at the end.
*/
const SCALE = 100;
/**
* `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two
* numbers reach Move through the *variables* – one node writes both, one node reads both back.
* Neither speed is on the Move node, so a node that dropped a port would leave the box still.
*/
const logicGraph = {
nodes: [
node('vp-update', 'OnUpdate', 0, 0),
node('vp-owner', 'Owner', 0, 300),
node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]),
node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]),
node('vp-move', 'Move', 560, 0, { scale: SCALE }),
],
edges: [
edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'),
edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'),
edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'),
edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'),
edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'),
edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'),
],
};
export async function variableNodes(t) {
const { check, sleep, evaluate, json, shot, click, locate, undo, entity, asset, mutate, stop } = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) =>
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
};
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
// 23a. A box wearing a behavior whose two speeds travel through its variables.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
id: 'vp-logic',
name: 'Speeds',
props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 },
variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
${MAKE_OBJECT}
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
`);
await sleep(400);
// 23b. The run: both variables are written by one node and read back by another, and the two
// speeds stay apart – 2 to the right for every 3 down.
await t.play();
await sleep(300);
const before = await entity('vp-box');
await sleep(1000);
const after = await entity('vp-box');
const dx = after.x - before.x;
const dy = after.y - before.y;
check(
'one node writes both variables, another reads both back, and the run uses them',
dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6,
`moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
);
await stop();
await sleep(300);
// 23c. The node draws one port per variable it names, on the side its type says.
await select('vp-set');
const vxIn = await portAt('vp-set', 'vx', false);
const vyIn = await portAt('vp-set', 'vy', false);
const vxOut = await portAt('vp-get', 'vx', true);
check(
'the picked variables are ports: inputs on Set, outputs on Get',
Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut),
JSON.stringify({ vxIn, vyIn, vxOut }),
);
// 23d. The inspector picks a port from the behavior's variables instead of having it typed.
const add = await locate('+ Variable');
check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
await shot('01-variable-ports');
await click(add.x, add.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
const grown = await portsOf('vp-set');
check(
'picking one adds it as a port, typed from the variable',
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number',
JSON.stringify(grown),
);
check('and the node grew that input', Boolean(await portAt('vp-set', 'vz', false)));
await shot('02-variable-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('vp-set')).length === 2);
// 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a
// rewiring. `vp-e3` runs from this port to Move's dx.
await select('vp-get');
await clickLast('vx');
await sleep(200);
await clickLast('vz');
await sleep(300);
const swapped = await portsOf('vp-get');
const edgeStill = await json(
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`,
);
check(
'a port pointed at another variable keeps its edge',
swapped[0].key === 'vz' && edgeStill.port === 'vz',
JSON.stringify({ swapped, edgeStill }),
);
await undo();
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
// 23f. A Pack keeps declaring its own fields, but a variable that already carries the shape can
// hand it over. A color field arrives as a number port – the graph has no colors.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic');
a.variables.push(
${JSON.stringify(
variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, undefined, [
{ key: 'shape', kind: 'number' },
{ key: 'tint', kind: 'color' },
{ key: 'cells', kind: 'list', items: 'number' },
]),
)},
${JSON.stringify(
variable('shapes', 'list', [], 'struct', [
{ key: 'x', kind: 'number' },
{ key: 'y', kind: 'number' },
]),
)},
);
a.props.piece = { shape: 0, tint: 0, cells: [] };
a.props.shapes = [];
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
`);
await sleep(300);
await select('vp-pack');
const from = await locate('From variable');
check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from));
await click(from.x, from.y);
await sleep(200);
await shot('03-from-variable');
await clickLast('piece');
await sleep(300);
const seeded = await portsOf('vp-pack');
check(
'picking a struct variable declares every field at once, a color among them as a number',
JSON.stringify(seeded) ===
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"number"},{"key":"cells","kind":"list"}]',
JSON.stringify(seeded),
);
check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false)));
await shot('04-fields-seeded');
await undo();
await sleep(300);
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
// 23g. A list of structs declares the shape of its items, which is the bundle a Pack builds for it.
await click(from.x, from.y);
await sleep(200);
await clickLast('shapes (item)');
await sleep(300);
const fromItem = await portsOf('vp-pack');
check(
'a list of structs offers its item shape too',
fromItem.map((p) => p.key).join(',') === 'x,y',
JSON.stringify(fromItem),
);
await undo();
await sleep(300);
// 23h. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
await mutate(`
__gm.state.project.assets.find((x) => x.id === 'vp-logic').graph.nodes.push(
${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))},
);
`);
await sleep(200);
await select('vp-start');
// `locate` throws when the text is absent, which is the wrong shape for "is it there?".
const helper = await json(`__gm.locate('From variable', false)`);
check(
'a behavior start declares inputs, not fields, and is not offered a shape',
Boolean(await locate('+ Input')) && helper === null,
JSON.stringify(helper),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}