Files
feuerwerk/tools/e2e/16-container.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

321 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 { instanceChild } from '../lib/builders.mjs';
import { openTab } from './lib/scaffold.mjs';
/**
* The Container tab: editing a template with the scene view's tools. Relies on the container step
* having left two instances of a two-crate container in the scene.
*/
const NAME = 'Crate pair';
export async function containerTab(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
shot,
click,
clickText,
drag,
dragAsset,
mouse,
press,
undo,
state,
object,
objects,
storedObjects,
showTab,
} = t;
await press('Escape');
const containerId = await json("(__gm.state.project.assets.find(a => a.type === 'container') ?? {}).id");
// The overflow step renames assets to absurd lengths; a readable name makes the header checkable.
await evaluate(
`(() => { __gm.state.project.assets.find(a => a.id === ${JSON.stringify(containerId)}).name = ${JSON.stringify(NAME)}; __gm.state.preview(); })()`,
);
await sleep(200);
// 16a. The tab opens on the container the project has, and its header says which one.
await openTab(t, 'container');
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
const count = template.length;
check(
'the Container tab opens showing a container',
(await json('__gm.tab()')) === 'container' && (await json('__gm.container.current()')) === containerId,
JSON.stringify({ tab: await json('__gm.tab()'), shown: await json('__gm.container.current()') }),
);
check(
'the header names the container and counts its template objects',
Boolean(await evaluate(`__gm.locate(${JSON.stringify(NAME)})`)) &&
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`${count} objects`)})`)),
JSON.stringify({ name: NAME, count }),
);
check(
'the hierarchy panel retitles itself for the container it lists',
// A panel draws its title in capitals, so that is what there is to find.
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`Container: ${NAME}`.toUpperCase())})`)),
);
await shot('30-container-tab');
// 16b. The hierarchy follows the visible tab: it lists the template, not the scene.
const rows = await json('__gm.hierarchyOrder()');
check(
'the hierarchy lists the template objects while the tab is open',
rows.length > 0 && rows.every((id) => template.some((o) => o.id === id)),
JSON.stringify(rows),
);
// 16c. Clicking a template object selects it against the container as its holder – the same
// id set as the scene would give, but a different holder.
const root = template.find((o) => o.parentId === '');
const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
await click(at.x, at.y);
let s = await state();
check(
'clicking a template object selects it against the container, not the scene',
s.selection?.kind === 'object' &&
s.selection.holderId === containerId &&
s.selection.objectId === root.id,
JSON.stringify(s.selection),
);
// 16d. The point of the tab: a drag writes the template, and every instance follows it – except
// one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
`__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
);
// The instances resolve this template object under `<instance>~${root.id}`; nothing of it is
// stored, so the resolved list is the only place to read it.
const instanceXs = () =>
json(
`Object.fromEntries(__gm.sceneObjects().filter(o => o.id.endsWith('~${root.id}')).map(o => [o.id, o.position.x]))`,
);
const shown = await instanceXs();
const [dirtyId, ...cleanIds] = Object.keys(shown);
const dirtyInstance = dirtyId.slice(0, dirtyId.indexOf('~'));
const setDeviation = (body) =>
mutate(
`const edits = __gm.state.activeScene.objects.find(o => o.id === '${dirtyInstance}').instanceEdits;
${body}`,
);
await setDeviation(`edits['${root.id}'] = { position: { x: ${shown[dirtyId] + 7} }, overrides: {} }`);
const before = await templateX();
await drag(at.x, at.y, at.x + 90, at.y);
const after = await templateX();
const afterDrag = await instanceXs();
check(
'dragging in the Container tab moves the template',
after === before + 90,
JSON.stringify({ before, after }),
);
check(
'the instances that still matched the template follow it',
cleanIds.length > 0 && cleanIds.every((id) => afterDrag[id] === shown[id] + 90),
JSON.stringify({ shown, afterDrag, cleanIds }),
);
check(
'the instance that was moved on its own keeps its own position',
afterDrag[dirtyId] === shown[dirtyId] + 7,
JSON.stringify({ was: shown[dirtyId], now: afterDrag[dirtyId] }),
);
await undo();
const undone = await instanceXs();
check('undo takes the template move back', (await templateX()) === before);
check(
'and takes the instances back with it',
cleanIds.every((id) => undone[id] === shown[id]),
JSON.stringify({ shown, undone }),
);
await setDeviation(`delete edits['${root.id}']`);
// 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this
// tab adds one object to the template, not to the scene.
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
// instance instead, since every one of them resolves the new template object too.
const sceneStored = (await storedObjects()).length;
const sceneListed = (await objects()).length;
const instanceCount = Object.keys(await instanceXs()).length;
const canvas = await json('__gm.container.center()');
await dragAsset('crate', canvas.x + 40, canvas.y + 40);
const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
s = await state();
check(
'Place in container puts one object into the template and selects it there',
grown.length === count + 1 &&
s.selection?.holderId === containerId &&
grown.some((o) => o.id === s.selection.objectId),
JSON.stringify({ before: count, after: grown.length, selection: s.selection }),
);
check('the scene stores nothing more for it', (await storedObjects()).length === sceneStored);
check(
'but every placed instance shows the new template object at once',
(await objects()).length === sceneListed + instanceCount,
JSON.stringify({ listed: (await objects()).length, was: sceneListed, instances: instanceCount }),
);
await shot('31-container-object-added');
// 16f. Delete works on a template object like on any other, as one undo step.
await press('Delete');
check(
'Delete removes a template object',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count,
);
await undo();
check(
'undo puts it back',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length ===
count + 1,
);
await press('Delete');
// 16g. Fit frames the content. Proven against an object parked far outside the view: without
// fitting it is nowhere near the canvas.
await mutate(
`const obj = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); obj.position.x = 3000`,
);
const area = await json('__gm.container.rect()');
const inside = (p) => p.x >= area.x && p.x <= area.x + area.w && p.y >= area.y && p.y <= area.y + area.h;
const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
await clickText('Fit');
const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
check(
'Fit brings an object parked far outside back into view',
!inside(far) && inside(fitted),
JSON.stringify({ area, far, fitted }),
);
await evaluate(
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = ${before}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await shot('32-container-fitted');
// 16h. A template object exists only in this tab, so selecting one from elsewhere switches to
// it – the rule a graph node and a texture frame already follow. A scene object does not.
await showTab('scene');
await select({ kind: 'object', holderId: containerId, objectId: root.id });
check(
'selecting a template object switches to the Container tab',
(await json('__gm.tab()')) === 'container',
);
await showTab('scene');
await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'otex' })");
check('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene');
check(
'the hierarchy is back on the scene with it',
(await json('__gm.hierarchyOrder()')).includes('otex'),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
// 16i. Switching to another container is a plain asset selection – the same thing the header's
// picker does. The hierarchy has to follow it, title and rows both.
await showTab('container');
await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
const copy = JSON.parse(JSON.stringify(src));
copy.id = 'c2'; copy.name = 'Spare box';
copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
__gm.state.project.assets.push(copy)`);
await select({ kind: 'asset', assetId: 'c2' });
check(
'picking another container moves the tab and the hierarchy to it',
(await json('__gm.container.current()')) === 'c2' &&
JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) &&
Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")),
JSON.stringify({
shown: await json('__gm.container.current()'),
rows: await json('__gm.hierarchyOrder()'),
}),
);
await shot('33-container-switched');
// 22i2. Dragging the open container onto its own template would nest it in itself. The drag
// has to say so while it hovers – an outline that only turns out to be a lie on release
// is worse than none – and the drop has to change nothing.
const canvas2 = await json('__gm.container.center()');
const own = await json("__gm.assetRow('c2')");
await mouse('mouseMoved', own.x, own.y);
await mouse('mousePressed', own.x, own.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
own.x + ((canvas2.x - own.x) * i) / 8,
own.y + ((canvas2.y - own.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const refused = await json('__gm.container.dropPreview()');
check(
'a container dragged over its own template is refused while it hovers',
Boolean(refused) && /nests forever/.test(refused.rejection ?? ''),
JSON.stringify(refused),
);
// Picking it up selects it; the tab must not follow that, or the target would swap mid-drag.
check(
'and the tab keeps showing the container being dropped into',
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ shown: await json('__gm.container.current()') }),
);
await shot('33b-container-cycle-refused');
const ownBefore = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await mouse('mouseReleased', canvas2.x, canvas2.y);
await sleep(200);
const ownAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'releasing it changes nothing',
ownAfter.length === ownBefore.length,
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
);
// 16j. Nesting: another container, though, may go in – and the tab it lands in stays put.
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'and placing it nests it inside that one',
nestedAfter.length === nested.length + 1 &&
nestedAfter.some((o) => o.assetId === containerId) &&
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
// 16k. Nesting resolves all the way down: placing the outer container in the scene stores one
// object, and the inner container's content appears under `<instance>~<outer>/<inner>`.
const innerObject = nestedAfter.find((o) => o.assetId === containerId);
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
await showTab('scene');
const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length;
const spot = await json('__gm.viewportCenter()');
await dragAsset('c2', spot.x + 80, spot.y + 70);
const outer = (await storedObjects()).find((o) => o.assetId === 'c2');
const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id));
const listedIds = (await objects()).map((o) => o.id);
check(
'a container nested in a container template resolves through both levels',
(await storedObjects()).length === storedBefore + 1 &&
(await objects()).length === listedBefore + 1 + nestedAfter.length + innerTemplate.length &&
deep.every((id) => listedIds.includes(id)),
JSON.stringify({
stored: (await storedObjects()).length - storedBefore,
listed: (await objects()).length - listedBefore,
deep: deep.filter((id) => !listedIds.includes(id)),
}),
);
check(
'and the nested content hangs below the nested container object',
(await object(deep[0])).parentId === instanceChild(outer.id, innerObject.id),
JSON.stringify(await object(deep[0])),
);
await shot('34-container-nested');
await undo(); // the placement
await undo(); // the nesting
}