A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
game-maker / suite (push) Successful in 6m48s

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.
This commit is contained in:
admin committed 2026-09-16 08:08:10 +02:00
1 parent c990dbcb26
commit 7e5003b590
41 files changed
+362 -732

No files matched your search

+9 -73
View File
@@ -6,6 +6,7 @@ export async function graph(t) {
const {
check,
sleep,
saved,
evaluate,
json,
find,
@@ -26,6 +27,7 @@ export async function graph(t) {
api,
navigate,
projectId,
showTab,
} = t;
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
const graphCounts = async () => {
@@ -36,8 +38,7 @@ export async function graph(t) {
// 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
await showTab('graph');
check(
'graph editor shows the selected behavior',
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph',
@@ -79,13 +80,10 @@ export async function graph(t) {
const bare = await json('__gm.graph.emptySpot()');
const beforeClickPick = await graphCounts();
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
await sleep(150);
const stopRow = await locate('Stop game'); // nothing else on screen shows this label
await click(stopRow.x, stopRow.y);
await sleep(100);
const afterClickPick = await graphCounts();
check(
'clicking a palette row adds exactly one node',
@@ -99,7 +97,6 @@ export async function graph(t) {
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
// Unfiltered the palette holds far more nodes than fit, so it grows to the full height it may
// have – and that height is the canvas minus a tenth at each end. Shifting it up to fit used to
@@ -116,9 +113,7 @@ export async function graph(t) {
JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }),
);
await typeText('log');
await sleep(100);
await press('Enter');
await sleep(250);
const after13 = await graphCounts();
s = await state();
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
@@ -134,9 +129,7 @@ export async function graph(t) {
// Enter the first row alphabetically, which was `Run behavior`.
const beforeRank = await graphCounts();
await click(bare.x, bare.y, 'right');
await sleep(100);
await typeText('variable');
await sleep(150);
const ranked = await json('__gm.graph.paletteRows()');
const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable');
const lastNamed = ranked.map(hits).lastIndexOf(true);
@@ -147,7 +140,6 @@ export async function graph(t) {
JSON.stringify(ranked),
);
await press('Enter');
await sleep(250);
s = await state();
const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check(
@@ -156,7 +148,6 @@ export async function graph(t) {
JSON.stringify(rankPick),
);
await undo();
await sleep(150);
check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes);
// 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An
@@ -240,7 +231,6 @@ export async function graph(t) {
await shot('18c-drop-into-empty');
const clampRow = await locate('Clamp');
await click(clampRow.x, clampRow.y);
await sleep(200);
const afterDrop = await graphCounts();
const wired = await state();
const clampEdge = (await asset('mover')).graph.edges.find(
@@ -272,7 +262,6 @@ export async function graph(t) {
await mouse('mouseReleased', empty.x, empty.y);
await sleep(150);
await press('Escape');
await sleep(150);
const cancelled = await graphCounts();
check(
'escaping the palette clears the edge and adds nothing',
@@ -284,10 +273,8 @@ export async function graph(t) {
// 6f. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
await sleep(200);
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
await press('Delete');
await sleep(150);
const deleted = await graphCounts();
check(
'Delete removes the node and its edge',
@@ -324,8 +311,7 @@ export async function graph(t) {
typeof fpsPlay === 'number' && fpsPlay > 0,
`fps=${fpsPlay}`,
);
await evaluate(`__gm.showTab('graph')`);
await sleep(100);
await showTab('graph');
const holding = holdKey('ArrowRight', 300);
await sleep(250);
await shot('19-graph-live');
@@ -334,10 +320,8 @@ export async function graph(t) {
// 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
await clickText('+ Variable');
await clickText('Number');
await sleep(100);
check(
'adding a variable declares it on the behavior',
(await variableKeys()) === 'speed,var1',
@@ -358,13 +342,11 @@ export async function graph(t) {
);
check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…')));
await select(null);
await sleep(100);
check(
'graph editor keeps its behavior when nothing is selected',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
await select({ kind: 'asset', assetId: 'cam' });
await sleep(100);
check(
'graph editor keeps its behavior for a non-behavior asset',
(await evaluate(`__gm.graph.current()`)) === 'mover',
@@ -372,17 +354,14 @@ export async function graph(t) {
// The picker is the *last* "Mover" on screen: the assets panel shows one too.
const pickerAt = await locate('Mover', true);
await click(pickerAt.x, pickerAt.y);
await sleep(150);
const spawnerRow = await locate('Spawner', true);
await click(spawnerRow.x, spawnerRow.y);
await sleep(200);
check(
'the header picker switches the graph to another behavior',
(await evaluate(`__gm.graph.current()`)) === 'spawner',
String(await evaluate(`__gm.graph.current()`)),
);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(100);
check(
'graph editor rebinds when a behavior is selected again',
(await evaluate(`__gm.graph.current()`)) === 'mover',
@@ -391,7 +370,6 @@ export async function graph(t) {
// 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// under it included – and the left button no longer pans at all.
await clickText('Fit');
await sleep(250);
const positions = async () =>
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
const area = await json('__gm.graph.canvasRect()');
@@ -415,7 +393,6 @@ export async function graph(t) {
// Fit again: the pan just now pushed a node out of view, and a band takes only what it sees.
await clickText('Fit');
await sleep(250);
// An empty spot: after a fit every node sits at least a margin away from the corner.
const corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
@@ -464,7 +441,6 @@ export async function graph(t) {
const snapLabel = await locate('Snap');
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
check(
'the toggle switches it on without moving a single node',
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
@@ -473,7 +449,6 @@ export async function graph(t) {
// A single node: dropped between two grid lines, it lands on the nearer one.
await click(corner.x, corner.y);
await sleep(150);
const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const snapBy = await units(50);
@@ -521,9 +496,7 @@ export async function graph(t) {
// Switching it off has to give the pointer its exact drop position back.
await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
await click(corner.x, corner.y);
await sleep(150);
const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const offBy = await units(50);
@@ -538,13 +511,11 @@ export async function graph(t) {
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
);
await undo();
await sleep(150);
// 6m. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();
await press('Delete');
await sleep(200);
const afterDelete = await graphCounts();
check(
'Delete removes every selected node, edges with them',
@@ -563,7 +534,6 @@ export async function graph(t) {
// and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
await sleep(250);
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
await click(axisRow.x, axisRow.y);
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis');
@@ -591,7 +561,6 @@ export async function graph(t) {
);
await sleep(250);
await clickText('Fit');
await sleep(300);
const rects = [];
for (const node of (await asset('mover')).graph.nodes)
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
@@ -614,14 +583,12 @@ export async function graph(t) {
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await sleep(250);
check('the parked node is back where it started', (await positions()) === before13i, await positions());
// 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
// comes back while either of its two nodes is selected.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
await sleep(250);
const allEdges = (await asset('mover')).graph.edges;
const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0];
const id = JSON.stringify(edge.id);
@@ -633,12 +600,9 @@ export async function graph(t) {
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(outPort.x, outPort.y, 'right');
await sleep(150);
check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
await clickText('Hide link');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
const hidden = await edgeNow();
check(
'hiding changes nothing but a flag – the connection is still there',
@@ -660,21 +624,17 @@ export async function graph(t) {
// Selecting either end lends the curve back – the source first, then the target.
const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`);
await click(sourceAt.x, sourceAt.y);
await sleep(200);
check('selecting the node at one end brings the curve back', (await drawn()) === true);
const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`);
await click(targetAt.x, targetAt.y);
await sleep(200);
check('selecting the node at the other end does too', (await drawn()) === true);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
check('deselecting hides it again', (await drawn()) === false);
// The mark is the handle: clicking it selects the node at the far end, which is also what
// puts the curve back – so it answers "where does this go?" in one click.
const mark2 = await json(`__gm.graph.foldMark(${id}, true)`);
await click(mark2.x, mark2.y);
await sleep(200);
check(
'clicking the mark jumps to the node at the far end',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]),
@@ -684,7 +644,6 @@ export async function graph(t) {
await undo();
await select({ kind: 'asset', assetId: 'mover' });
await sleep(250);
const shown = await edgeNow();
check(
'undo unhides the link, with nothing left behind',
@@ -737,12 +696,9 @@ export async function graph(t) {
);
await click(forkPort.x, forkPort.y, 'right');
await sleep(150);
check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
await clickText('Hide 2 links');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const ids = forked.map((e) => JSON.stringify(e.id));
const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`);
const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`);
@@ -767,7 +723,6 @@ export async function graph(t) {
// The bundled mark is still a handle: one click selects every far end, which is also what
// lends all of its curves back at once.
await click(markA.x, markA.y);
await sleep(250);
const picked = await json('__gm.graph.selected()');
check(
'clicking a bundled mark selects every node the port feeds',
@@ -781,15 +736,12 @@ export async function graph(t) {
);
await click(markA.x, markA.y, 'right');
await sleep(150);
check(
'the mark menu shows both links back in one go',
Boolean(await evaluate(`__gm.locate('Show 2 links')`)),
);
await clickText('Show 2 links');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const unfolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
);
@@ -801,7 +753,6 @@ export async function graph(t) {
// One step of undo takes both back – hiding two links was one command, so it is one undo.
await undo();
await select({ kind: 'asset', assetId: 'mover' });
await sleep(250);
const refolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
);
@@ -811,7 +762,6 @@ export async function graph(t) {
JSON.stringify(refolded.map((e) => e.folded)),
);
await undo();
await sleep(200);
const forkedNow = async () =>
(await asset('mover')).graph.edges.filter(
@@ -821,13 +771,11 @@ export async function graph(t) {
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(portNow.x, portNow.y, 'right');
await sleep(150);
const offered = await find('Disconnect 2 links');
if (offered) await click(offered.x, offered.y);
await sleep(250);
const cut = await forkedNow();
await undo();
await sleep(250);
check(
'disconnecting both links at a port is one undo step',
Boolean(offered) && cut === 0 && (await forkedNow()) === 2,
@@ -838,12 +786,10 @@ export async function graph(t) {
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
await sleep(250);
const edgesBefore13q = (await graphCounts()).edges;
const openRect = await json(`__gm.graph.nodeRect('n-move')`);
const chevron = await json(`__gm.graph.chevronAt('n-move')`);
await click(chevron.x, chevron.y);
await sleep(200);
const shut = await json(`__gm.graph.nodeRect('n-move')`);
const zoom = openRect.w / 176;
check(
@@ -902,12 +848,9 @@ export async function graph(t) {
for (const port of ['x', 'y']) {
const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`);
await click(at.x, at.y, 'right');
await sleep(150);
await clickText('Hide link');
await sleep(200);
}
await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const markDx = await json(`__gm.graph.foldMark('e3', false)`);
const markDy = await json(`__gm.graph.foldMark('e4', false)`);
check(
@@ -919,9 +862,7 @@ export async function graph(t) {
await undo();
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
await sleep(150);
await click(title.x, title.y, 'right');
await sleep(150);
check(
'a mixed selection is offered both ways',
Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) &&
@@ -929,14 +870,11 @@ export async function graph(t) {
);
await press('Escape');
await undo();
await sleep(150);
check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined);
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
await sleep(150);
const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
await click(openTitle.x, openTitle.y, 'right');
await sleep(150);
const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`);
check('two expanded nodes are offered as one', collapseBoth !== null);
if (collapseBoth) await click(collapseBoth.x, collapseBoth.y);
@@ -945,7 +883,6 @@ export async function graph(t) {
const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed];
check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]');
await undo();
await sleep(150);
check(
'and one undo takes all of it back',
JSON.stringify(await both()) === '[null,null]',
@@ -957,16 +894,15 @@ export async function graph(t) {
const again = await json(`__gm.graph.chevronAt('n-move')`);
await click(again.x, again.y);
const savedMove = async () => {
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
const saved = await api(`projects/${projectId}`);
return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move');
await saved();
const doc = await api(`projects/${projectId}`);
return doc.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move');
};
check('the collapse reaches the saved file', (await savedMove()).collapsed === true);
await navigate();
await sleep(500);
await select({ kind: 'asset', assetId: 'mover' });
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
await showTab('graph');
const reloaded = await json(`__gm.graph.nodeRect('n-move')`);
check(
'and a reloaded project draws it collapsed again',
@@ -977,5 +913,5 @@ export async function graph(t) {
await click(reopen.x, reopen.y);
check('expanding leaves no trace in the file', !('collapsed' in (await savedMove())));
await evaluate(`__gm.showTab('scene')`);
await showTab('scene');
}