Files
feuerwerk/tools/e2e/07-parenting.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

156 lines
5.7 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 { same } from '../lib/harness.mjs';
/**
* Hierarchy / parenting. Gives both crates unique names (later steps locate them by these),
* then parents by drag, by the inspector menu and unparents from the context menu – the world
* position is kept every time.
*/
export async function parenting(t) {
const {
check,
json,
shot,
click,
clickText,
drag,
locate,
typeText,
press,
undo,
mutate,
select,
object,
entity,
stop,
} = t;
// 7a. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
// position (local becomes the difference) and is listed right below its parent.
await mutate(`
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
__gm.state.select(null)`);
const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
let o2 = await object('o2');
check(
'dropping a row onto another parents it, keeping the world position',
o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80,
JSON.stringify(o2),
);
const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
const o1At = order.indexOf('o1');
check(
'children are listed directly below their parent',
same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']),
JSON.stringify(order),
);
await shot('20-parented');
// 7b. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
await press('a', { ctrl: true });
await typeText('-50');
await press('Enter');
o2 = await object('o2');
const parent = await object('o2').then((o) => object(o.parentId));
const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y };
check(
'moving the parent moves the child in world space (local unchanged)',
o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }),
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
);
// 7c. Play: entities mirror the hierarchy; undo restores parent and position.
await select(null);
await press('Enter', { ctrl: true });
const e2 = await entity('o2');
check(
'play instantiates the parent link and world positions',
e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270,
JSON.stringify(e2),
);
await stop();
await undo(); // move
await undo(); // parent
o2 = await object('o2');
check(
'undo restores top level and the original local position',
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2),
);
// 7d. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
await clickText('Blue crate');
await clickText('None'); // the Parent dropdown, labelled with the current parent
const entries = await json('__gm.overlayTexts()');
check(
'the parent picker offers a search box and lists the candidates',
entries.includes('Search…') && entries.includes('None') && entries.includes('Red crate'),
JSON.stringify(entries),
);
await typeText('red');
const filtered = await json('__gm.overlayTexts()');
check(
'typing filters the picker down to the matching entries',
same(filtered, ['red', 'Red crate']),
JSON.stringify(filtered),
);
await shot('30-parent-search');
await press('Enter'); // takes the first match
o2 = await object('o2');
check(
'inspector Parent menu parents the object',
o2.parentId === 'o1' && o2.position.x === 270,
JSON.stringify(o2),
);
check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), []));
const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right');
await clickText('Unparent (move to top level)');
o2 = await object('o2');
check(
'Unparent moves the object to the top level, keeping its world position',
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2),
);
// 7e. The gap above a row is a different drop than the row itself: it orders the dragged
// object among that row's siblings instead of putting it inside, and moves nothing.
const rowsBefore = await json('__gm.hierarchyOrder()');
check(
'the blue crate is listed after the red one to begin with',
rowsBefore.indexOf('o2') > rowsBefore.indexOf('o1'),
JSON.stringify(rowsBefore),
);
const redRow3 = await locate('Red crate');
const blueRow3 = await locate('Blue crate');
// 10 px above the centre of a 26 px row is its top quarter – the gap, not the row.
await drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10);
const rowsAfter = await json('__gm.hierarchyOrder()');
o2 = await object('o2');
check(
'dropping into the gap reorders instead of parenting',
rowsAfter.indexOf('o2') < rowsAfter.indexOf('o1') && o2.parentId === '',
JSON.stringify({ rowsAfter, parentId: o2.parentId }),
);
check(
'and a reorder moves nothing in the world',
o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2.position),
);
await shot('31-reordered');
await undo();
check(
'undo puts the order back',
same(await json('__gm.hierarchyOrder()'), rowsBefore),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
}