Files
feuerwerk/tools/e2e/23-variable-nodes.mjs
T
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
2026-09-16 08:08:10 +02:00

332 lines
14 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, 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');
// 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`,
);
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));
}