Files
feuerwerk/tools/e2e/23-variable-nodes.mjs
adminandClaude Opus 5.5 a5184b3368
game-maker / gate (push) Failing after 3m4s
game-maker / suite (push) Successful in 14m57s
Let the rows of a Variables node be dragged into a new order
The Variables and Properties nodes listed their picked rows in the
inspector, but unlike Start, End and struct rows they could not be
reordered. The drag dots also sat right on the edge of the row and
touched the kind mark.

- buildPickedPortSection gathers each row with its warning line as one
  block and hands the blocks to sortBlocks. A move reorders the ports
  on the node and is one undo step. Properties uses the same section
  and gains the same reordering.
- Grip draws the dots 3 px in from the left edge and moves the kind
  mark 3 px clear of them (DOTS_PAD). The column keeps its width, so
  the name fields do not shift. Rows without dots are unchanged.

Tests:
- block-drag, reorder, variable-choices: 3 files, 13 tests, all green.
  block-drag now expects the mark 11 px in with dots, 0 without.
- e2e 23 on melektaus-atx: ALL PASSED, 32 checks (+3 for dragging vy
  before vx and undoing it). It ran before the padding change, which
  touches no checks there.
- The gate did not run before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:08:59 +02:00

369 lines
16 KiB
JavaScript
Raw Permalink 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, written } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* The Variables node: its ports are the behavior's variables, each read at any time and written
* where it is switched to Set. The step checks the run, picking a port from the variable list,
* switching Set, 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 → Variables{set 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', 'Variables', 260, 0, { vx: 2, vy: 3 }, [written('vx', 'number'), written('vy', 'number')]),
node('vp-get', 'Variables', 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, json, shot, click, locate, undo, entity, asset, mutate, stop, typeText, press, has } =
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 t.select({ kind: 'node', assetId: 'vp-logic', nodeId, nodeIds: [nodeId] });
};
/** 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);
};
/** The Set checkbox of the newest variable row: the control right of the last "Set" label. */
const clickLastSet = async () => {
const box = await json(`__gm.inspectorField('Set', true)`);
await click(box.x, box.y);
return box;
};
// 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'));
`);
// 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();
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();
// 23c. Every variable is an output; a written one is an input too, and only a writing node has
// flow pins.
await select('vp-set');
const pins = {
setIn: Boolean(await portAt('vp-set', 'vx', false)),
setOut: Boolean(await portAt('vp-set', 'vx', true)),
getOut: Boolean(await portAt('vp-get', 'vx', true)),
getIn: Boolean(await portAt('vp-get', 'vx', false)),
getFlow: Boolean(await portAt('vp-get', 'exec', false)),
};
check(
'a written variable is an input and an output, a read one only an output, without flow pins',
pins.setIn && pins.setOut && pins.getOut && !pins.getIn && !pins.getFlow,
JSON.stringify(pins),
);
// 23d. The inspector picks a port from the behavior's variables instead of having it typed, and
// a picked one is read until it is switched to Set.
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 clickLast('vz');
const grown = await portsOf('vp-set');
check(
'picking one adds it as a read port, typed from the variable',
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number' && !grown[2].write,
JSON.stringify(grown),
);
check(
'and the node grew that output only',
Boolean(await portAt('vp-set', 'vz', true)) && !(await portAt('vp-set', 'vz', false)),
);
await clickLastSet();
check(
'switching it to Set gives it an input as well',
(await portsOf('vp-set'))[2]?.write === true && Boolean(await portAt('vp-set', 'vz', false)),
JSON.stringify(await portsOf('vp-set')),
);
const drawn = await json(`__gm.graph.nodeInputs('vp-set')`);
check(
'with nothing typed the node shows the value it writes beside the name, as the inspector does',
drawn?.includes('vz 0') && drawn.includes('vx 2'),
JSON.stringify(drawn),
);
await shot('02-variable-set');
await undo();
await undo();
check('undo takes both 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 clickLast('vz');
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();
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
// 23e-ii. The ports are reordered by dragging their mark, as on a start node: the order is the
// node's. A drop just above a mark lands in front of its row.
const grabbed = await json(`__gm.inspectorMark('vy', true)`);
const below = await json(`__gm.inspectorMark('vx', true)`);
check(
'a variable row shows the dots of a mark that drags',
grabbed?.dots === 'idle',
JSON.stringify(grabbed),
);
await t.drag(grabbed.x, grabbed.y, below.x, below.y - 6);
check(
'dragging a variable by its mark puts it in front of the other',
(await portsOf('vp-get')).map((p) => p.key).join(',') === 'vy,vx',
JSON.stringify(await portsOf('vp-get')),
);
await undo();
check('and undo puts it back', (await portsOf('vp-get')).map((p) => p.key).join(',') === 'vx,vy');
// 23f. Collapsed, the ports are out of sight, so the title says what the node does – and only then.
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
const toggle = async (id) => {
const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`);
await click(chevron.x, chevron.y);
};
/** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */
const drawnWhole = async (text) => {
const canvas = await json('__gm.graph.canvasRect()');
const hits = await json(`__gm.locateAll(${JSON.stringify(text)})`);
return hits.some((p) => p.x > canvas.x && p.x < canvas.x + canvas.w);
};
await toggle('vp-set');
await toggle('vp-get');
const shut = { set: await titleOf('vp-set'), get: await titleOf('vp-get') };
check(
'collapsed, a node writing two variables says so, and one only reading two says that',
shut.set === 'Set 2 variables' &&
shut.get === 'Get 2 variables' &&
(await drawnWhole('Set 2 variables')) &&
(await drawnWhole('Get 2 variables')),
JSON.stringify(shut),
);
await shot('02b-collapsed-titles');
const narrow = (await json(`__gm.graph.nodeRect('vp-get')`)).w;
await clickLastSet();
const switched = await titleOf('vp-get');
check(
'switching one to Set while collapsed retitles it and resizes it to the new title',
switched === 'Set vy' &&
(await drawnWhole('Set vy')) &&
(await json(`__gm.graph.nodeRect('vp-get')`)).w < narrow,
JSON.stringify({ switched, narrow }),
);
await undo();
await toggle('vp-set');
await toggle('vp-get');
const open = [await titleOf('vp-set'), await titleOf('vp-get')];
check(
'expanded again, both are plain Variables nodes',
open.join() === 'Variables,Variables',
JSON.stringify(open),
);
// 23g. 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: [] }, [
{ key: 'shape', kind: 'number' },
{ key: 'tint', kind: 'color' },
{ key: 'cells', kind: 'list' },
]),
)},
${JSON.stringify(variable('shapes', 'list', [{ x: 0, y: 0 }]))},
);
a.props.piece = { shape: 0, tint: 0, cells: [] };
a.props.shapes = [{ x: 0, y: 0 }];
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
`);
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 shot('03-from-variable');
await clickLast('piece');
const seeded = await portsOf('vp-pack');
check(
'picking a struct variable declares every field at once, each of its own kind',
JSON.stringify(seeded) ===
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"color"},{"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();
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
// 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack
// builds for such a list.
await click(from.x, from.y);
await clickLast('shapes (item)');
const fromItem = await portsOf('vp-pack');
check(
'a list offers the shape of the struct items it holds',
fromItem.map((p) => p.key).join(',') === 'x,y',
JSON.stringify(fromItem),
);
await undo();
// 23i. 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 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),
);
// 23j. From the palette the node comes empty; picking a variable keeps its title, and switched to Set
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
// Bare canvas below every node, so the click cannot land on the Pack.
const bareSpot = await json('__gm.graph.emptySpot()');
await click(bareSpot.x, bareSpot.y, 'right');
// Enter takes the first hit in category order, so search by a phrase only its description has.
await typeText('switched to Set');
await press('Enter');
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added));
const variableId = added?.id ?? '';
const untitled = await titleOf(variableId);
const bare = !(await portAt(variableId, 'exec', false));
await clickLast('+ Variable');
await clickLast('vz');
await clickLastSet();
check(
'it starts without pins, and picking its variable leaves the title Variables',
untitled === 'Variables' && bare && (await titleOf(variableId)) === 'Variables',
JSON.stringify({ untitled, bare, titled: await titleOf(variableId) }),
);
check('switched to Set it grows its flow pins', Boolean(await portAt(variableId, 'exec', false)));
await shot('05-variable-node');
await mutate(`
const g = __gm.state.project.assets.find((x) => x.id === 'vp-logic').graph;
g.nodes.push(
${JSON.stringify(node('vp-add', 'Add', 800, 200, { b: 1 }))},
${JSON.stringify(node('vp-gone', 'Variables', 800, 500, {}, [port('gone', 'number')]))},
);
g.edges.push(
${JSON.stringify(edge('vp-e7', 'vp-move', 'exec', variableId, 'exec'))},
${JSON.stringify(edge('vp-e8', variableId, 'vz', 'vp-add', 'a'))},
${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))},
);
`);
await select('vp-gone');
check(
'a port naming a variable the behavior does not have is marked in the inspector',
await has('"gone" is not a variable of this behavior.'),
);
await t.play();
await sleep(1000);
const counted = (await entity('vp-brain'))?.props.vz;
await stop();
check(
'in the run it reads through its own output and writes the sum back, so vz climbs',
typeof counted === 'number' && counted > 20,
`vz = ${counted} after ~1 s`,
);
// 23k. An object variable: declared from the menu like any other, but with nothing to type – it is
// set by a graph while the game runs, so the row says (null) and the file keeps only the
// empty object's place.
await t.select({ kind: 'asset', assetId: 'vp-logic' });
await clickLast('+ Variable');
await clickLast('Object');
const declared = (await asset('vp-logic')).variables.at(-1);
check(
'an Object variable is declared from the menu and stores nothing as its value',
declared?.kind === 'object' && declared.default?.$object === '' && (await has('(null)')),
JSON.stringify(declared),
);
await shot('06-object-variable');
await undo();
check(
'undo takes it back',
(await asset('vp-logic')).variables.every((v) => v.kind !== 'object'),
);
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();
`);
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));
}