Files
feuerwerk/tools/e2e/24-property-nodes.mjs
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

283 lines
11 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, propertiesOf, variable, written } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Properties: properties of the object handed in, each read at any time and written where it is
* switched to Set. Its type decides what `+ Property` offers. The step brings its own box and
* behavior and removes them again.
*/
/** `On update → Properties{width 40, color red} on the owner`, whose width is then read into `seen`. */
const logicGraph = {
nodes: [
node('pp-update', 'OnUpdate', 0, 0),
node('pp-owner', 'Owner', 0, 240),
node('pp-set', 'Properties', 260, 0, { ...propertiesOf('sprite'), width: 40, color: 0xff0000 }, [
written('width', 'number'),
written('color', 'color'),
]),
node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]),
node('pp-any', 'Properties', 260, 420),
node('pp-beh', 'Properties', 560, 420, propertiesOf('behavior')),
],
edges: [
edge('pp-e1', 'pp-update', 'exec', 'pp-set', 'exec'),
edge('pp-e2', 'pp-owner', 'object', 'pp-set', 'object'),
edge('pp-e3', 'pp-set', 'exec', 'pp-store', 'exec'),
edge('pp-e4', 'pp-set', 'width', 'pp-store', 'seen'),
],
};
export async function propertyNodes(t) {
const { check, evaluate, json, shot, click, doubleClick, locate, find, undo, entity, asset } = t;
const { mutate, stop, typeText, press } = t;
const portsOf = async (nodeId) =>
((await asset('pp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []).map(
(p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`,
);
const title = (nodeId) => json(`__gm.graph.nodeLabel(${JSON.stringify(nodeId)})`);
const settings = async (nodeId) => (await json(`__gm.graph.nodeSettings(${JSON.stringify(nodeId)})`)) ?? [];
const select = async (nodeId) => {
await t.select({ kind: 'node', assetId: 'pp-logic', nodeId, nodeIds: [nodeId] });
};
/** A port section takes its choices when it is built, and re-selecting the same node is a no-op. */
const rebuildInspector = async () => {
await evaluate('__gm.state.select(null)');
await select('pp-set');
};
/** 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);
};
const offered = async (shotName) => {
await clickLast('+ Property');
const texts = await json('__gm.overlayTexts()');
if (shotName) await shot(shotName);
await press('Escape');
return texts;
};
const addProperty = async (search) => {
await clickLast('+ Property');
await typeText(search);
await press('Enter');
};
const choose = async (setting, option) => {
const field = await json(`__gm.inspectorField(${JSON.stringify(setting)}, true)`);
await click(field.x, field.y);
await clickLast(option);
};
/** The name field, not the node's port or the unconnected-input row below it that say the same. */
const rename = async (from, to) => {
const canvas = await json('__gm.graph.canvasRect()');
const hits = await json(`__gm.locateAll(${JSON.stringify(from)})`);
const field = hits.find((p) => p.x > canvas.x + canvas.w);
await doubleClick(field.x, field.y);
await typeText(to);
await press('Enter');
};
// 24a. A box wearing a behavior that reshapes it through one node.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const a = structuredClone(p.assets.find((x) => x.id === 'mover'));
Object.assign(a, {
id: 'pp-logic',
name: 'Reshape',
props: { enabled: true, runInEditor: false, seen: 0 },
variables: ${JSON.stringify([variable('seen', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
${MAKE_OBJECT}
scene.objects.push(make('pp-box', 'crate', '', 900, 'Reshaped box'), make('pp-brain', 'pp-logic', 'pp-box', 900, 'Reshape'));
`);
// 24b. The run: one node writes both properties, and its own output reads the width back after.
await t.play();
const box = (await entity('pp-box'))?.props;
const seen = (await entity('pp-brain'))?.props.seen;
await stop();
check(
'one node writes several properties and reads them back',
box?.width === 40 && box?.color === 0xff0000 && seen === 40,
JSON.stringify({ width: box?.width, color: box?.color, seen }),
);
// 24c. Titled Properties; the type stands on the node as a setting row, chosen above the list it decides.
await select('pp-set');
const typeRow = await json(`__gm.inspectorField('Type', true)`);
const header = await locate('PROPERTIES', true);
check(
'the node is titled Properties and shows its type, chosen above its properties',
(await title('pp-set')) === 'Properties' &&
(await settings('pp-set')).join() === 'Type: Sprite' &&
typeRow &&
typeRow.y < header.y,
JSON.stringify({
title: await title('pp-set'),
rows: await settings('pp-set'),
type: typeRow?.y,
header: header.y,
}),
);
check(
'the node shows a typed-in color as a color',
(await find('color #ff0000')) !== null,
JSON.stringify(await json(`__gm.graph.nodeInputs('pp-set')`)),
);
await shot('01-sprite-properties');
// 24d. A Sprite offers the fields of a sprite, and nothing of the other types.
const sprite = await offered();
check(
"a Sprite offers a sprite's properties",
['enabled', 'width', 'height', 'color', 'texture'].every((k) => sprite.includes(k)) &&
!sprite.includes('radius') &&
!sprite.includes('zoom'),
sprite.join(' | '),
);
// 24d-ii. Which sprite the node will meet is only known at runtime, so it offers the parameters of
// every shader – each entry saying whose it is, and one shared name naming both shaders.
await mutate(`
__gm.state.project.assets.push({
id: 'pp-neon',
type: 'shader',
name: 'Neon glow',
props: {},
graph: {
nodes: [
{ id: 'pp-n1', type: 'ShaderProperty', x: 0, y: 0, params: { name: 'brightness', default: 1, min: 0, max: 2 } },
{ id: 'pp-n2', type: 'ShaderProperty', x: 0, y: 120, params: { name: 'glow', default: 1, min: 0, max: 2 } },
],
edges: [],
},
});
`);
await rebuildInspector();
const shared = await offered('02-shader-parameters');
check(
'a shader parameter names the shader it comes from, a shared name both of them',
shared.includes('brightness (Adjust colors, Neon glow)') &&
shared.includes('glow (Neon glow)') &&
!shared.includes('brightness') &&
shared.includes('width'),
shared.filter((s) => s.includes('(') || s === 'brightness' || s === 'width').join(' | '),
);
await mutate(`
const list = __gm.state.project.assets;
const i = list.findIndex((a) => a.id === 'pp-neon');
if (i >= 0) list.splice(i, 1);
`);
await rebuildInspector();
const alone = await offered();
check(
'with that shader gone the parameter names only the one left',
alone.includes('brightness (Adjust colors)'),
alone.filter((s) => s.includes('(')).join(' | '),
);
// 24e. Picked, then switched to Set: its typed-in value starts at the property's own default –
// a height of 100, not a bare 0 that would flatten the box.
await addProperty('height');
const grown = await portsOf('pp-set');
check(
'picking a property adds it as an output',
grown.join(',') === 'width:number:set,color:color:set,height:number',
grown.join(','),
);
const toggle = await json(`__gm.inspectorField('Set', true)`);
await click(toggle.x, toggle.y);
const inputs = await json(`__gm.graph.nodeInputs('pp-set')`);
check(
"switched to Set, its typed-in value starts at the property's own default",
inputs.includes('height 100'),
JSON.stringify(inputs),
);
await shot('03-height-set');
await undo();
await undo();
check('undo takes both back', (await portsOf('pp-set')).length === 2, (await portsOf('pp-set')).join());
// 24f. Another type offers its own fields; a port it does not have is marked, not dropped.
await choose('Type', 'Camera');
const camera = await offered();
check(
'switching to Camera shows it on the node and offers the fields of a camera',
(await settings('pp-set')).join() === 'Type: Camera' &&
camera.includes('zoom') &&
!camera.includes('texture'),
JSON.stringify({ rows: await settings('pp-set'), offered: camera }),
);
check(
'a port the new type does not have stays, marked',
(await portsOf('pp-set')).length === 2 && (await find('"color" is not a property of a Camera.')) !== null,
(await portsOf('pp-set')).join(),
);
await shot('04-camera');
await undo();
check('undo switches back', (await settings('pp-set')).join() === 'Type: Sprite', await settings('pp-set'));
// 24g. Any names its ports by hand, and the kind is picked when adding one. A name that happens
// to be a property's stays of the kind picked.
await select('pp-any');
const kinds = await offered();
check(
'Any offers the kinds to pick from, not property names',
['Number', 'Text', 'Boolean', 'Color', 'Texture', 'List', 'Struct'].every((k) => kinds.includes(k)) &&
!kinds.includes('width'),
kinds.join(' | '),
);
await addProperty('text');
check('a port is added of the kind picked', (await portsOf('pp-any'))[0] === 'property1:text');
await rename('property1', 'width');
check(
'and renamed by typing, keeping its kind',
(await portsOf('pp-any'))[0] === 'width:text',
(await portsOf('pp-any')).join(),
);
await evaluate("__gm.state.notice = ''");
await rename('width', 'object');
const notice = await json('__gm.state.notice');
check(
'the object input keeps its key: a port cannot take it',
(await portsOf('pp-any'))[0] === 'width:text' && notice.includes('"object" is taken'),
JSON.stringify({ ports: await portsOf('pp-any'), notice }),
);
await shot('04-any');
// 24h. A Behavior offers the variables of the behavior picked.
await select('pp-beh');
const before = await offered();
check(
'a Behavior offers nothing until one is picked',
before.length === 0 && (await find('Pick a behavior first.')) !== null,
JSON.stringify(before),
);
await choose('Behavior', 'Reshape (behavior)');
const vars = await offered();
check(
'picking the behavior offers its variables and shows it on the node',
vars.includes('seen') &&
vars.includes('enabled') &&
(await settings('pp-beh')).join() === 'Type: Behavior,Behavior: Reshape',
JSON.stringify({ rows: await settings('pp-beh'), offered: vars }),
);
await addProperty('seen');
check('a variable is picked as a port', (await portsOf('pp-beh')).join() === 'seen:number');
await shot('05-behavior');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'pp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'pp-box' && o.id !== 'pp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "pp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}