Files
feuerwerk/tools/e2e/13-frames.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

211 lines
8.3 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 { colourDistance } from '../lib/harness.mjs';
import { openTab } from './lib/scaffold.mjs';
/**
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
*
* Runs after the texture step, whose four-quadrant 64x48 picture gives every quadrant a colour
* of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question.
*/
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
const PALETTE = {
red: '#e63c3c',
green: '#3cc85a',
blue: '#466ef0',
white: '#f5f5f5',
plain: '#ff00ff',
};
/**
* Nearest known colour rather than an exact match: editor gizmos (a collider fill at 12 % alpha)
* tint a sample by a few percent, which still cannot make one quadrant read as another.
*/
function nearestColour(sample) {
let best = '';
let bestDistance = Infinity;
for (const [name, hex] of Object.entries(PALETTE)) {
const distance = colourDistance(sample, hex);
if (distance < bestDistance) {
bestDistance = distance;
best = name;
}
}
return best;
}
export async function frames(t) {
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo, wheel, showTab } = t;
// 13a. The tab exists and shows the texture the selection points at.
await press('Escape');
await evaluate('__gm.state.select(null)');
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`);
await openTab(t, 'texture');
check('the Texture tab opens', (await json('__gm.tab()')) === 'texture');
check(
'and shows the selected sheet',
(await json('__gm.texture.current()')) === texture,
`${await json('__gm.texture.current()')} vs ${texture}`,
);
await shot('33-texture-tab');
// 13b. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
// inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle.
const from = await json('__gm.texture.pointAt(33, 1)');
const to = await json('__gm.texture.pointAt(63, 23)');
check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to }));
await drag(from.x, from.y, to.x, to.y);
await sleep(500);
const cut = await json(
`(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []`,
);
check('dragging on the sheet cuts a frame', cut.length === 1, JSON.stringify(cut));
const frame = cut[0] ?? {};
check(
'the frame lands on the quadrant it was dragged over',
frame.x >= 32 && frame.y >= 0 && frame.x + frame.w <= 64 && frame.y + frame.h <= 48,
JSON.stringify(frame),
);
check(
'and is stored in whole pixels',
Number.isInteger(frame.x) && Number.isInteger(frame.y) && Number.isInteger(frame.w),
JSON.stringify(frame),
);
check(
'cutting a frame selects it, so the inspector edits what was just made',
(await json('__gm.state.selection?.kind')) === 'frame',
JSON.stringify(await json('__gm.state.selection')),
);
await shot('34-frame-cut');
// 13c. It is one undo step, not one per pointer move.
await undo();
check(
'undo removes the frame in one step',
(await json(
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).length`,
)) === 0,
);
await t.redo();
// 13d. A sprite wearing the frame shows that part of the sheet and nothing else.
await evaluate(`(() => {
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.color = 0xffffff;
sprite.props.texture = ${JSON.stringify(frame.id)};
__gm.state.preview();
})()`);
await showTab('scene');
await sleep(900);
const box = await json("__gm.objectRect('otex')");
check('the textured sprite is on screen again', Boolean(box) && box.w > 8, JSON.stringify(box));
if (box) {
const at = async (fx, fy) =>
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)];
const read = corners.map(nearestColour);
// Without the frame these corners read red / green / blue / white, as the texture step
// asserts – so four greens is the statement that the fill stayed inside one quadrant.
check(
'the whole sprite shows the one quadrant the frame cuts out',
read.every((c) => c === 'green'),
JSON.stringify({ corners, read }),
);
await shot('35-sprite-wearing-a-frame');
}
// 13e. Slicing offers a live count before it commits.
// Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below
// cannot be confused with a part of the picture.
await evaluate(`(() => {
__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff;
__gm.state.preview();
})()`);
await showTab('texture');
await clickText('Slice…');
await sleep(500);
const dialog = await json('__gm.overlayTexts()');
check(
'the slice dialog states what the numbers would produce',
dialog.some((text) => /^\d+ frames of \d+ × \d+ px$/.test(text)),
JSON.stringify(dialog),
);
check(
'and names the picture size it is cutting',
dialog.some((text) => text === 'Picture is 64 × 48 px'),
JSON.stringify(dialog),
);
await clickText('Slice');
await sleep(700);
const sliced = await json(
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).map((f) => f.name + ' ' + f.w + 'x' + f.h)`,
);
check(
'slicing 4x4 replaces the frames with sixteen 16x12 cells',
sliced.length === 16 && sliced.every((s) => s.endsWith('16x12')),
JSON.stringify(sliced.slice(0, 3)),
);
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
await shot('36-sliced-sheet');
// 13f. Zoomed in, the sheet is larger than the tab: nothing of it may be drawn next to the tab.
// The bug this guards against drew the checkerboard's base rectangle, the frame outlines
// and the name plates over the Assets panel, because only the picture itself was masked.
await showTab('texture');
const area = await json('__gm.texture.rect()');
// Points on the panel left of the tab and on the tab row above it – both were painted over.
const probes = [
...[0.2, 0.4, 0.6, 0.8].map((f) => ({ x: Math.round(area.x - 40), y: Math.round(area.y + f * area.h) })),
...[0.25, 0.5, 0.75].map((f) => ({ x: Math.round(area.x + f * area.w), y: Math.round(area.y - 10) })),
];
const readProbes = async () => {
const out = [];
for (const p of probes) out.push(await json(`__gm.pixelAt(${p.x}, ${p.y})`));
return out;
};
const quiet = await readProbes();
const centre = { x: Math.round(area.x + area.w / 2), y: Math.round(area.y + area.h / 2) };
for (let i = 0; i < 6; i++) await wheel(centre.x, centre.y, -240);
await sleep(500);
const topLeft = await json('__gm.texture.pointAt(0, 0)');
// Without this the probes below would prove nothing: they need a sheet that reaches past the edge.
check(
'zooming in pushes the sheet past the tab on every side',
topLeft.x < area.x && topLeft.y < area.y,
JSON.stringify({ topLeft, area }),
);
await shot('36b-zoomed-sheet');
const loud = await readProbes();
const changed = probes
.map((p, i) => ({ ...p, was: quiet[i], now: loud[i] }))
.filter((p) => p.was !== p.now);
check('a zoomed sheet paints nothing outside the tab', changed.length === 0, JSON.stringify(changed));
check(
'and no frame name is drawn past the tab either',
(await json('__gm.textOverflows()')).length === 0,
JSON.stringify(await json('__gm.textOverflows()')),
);
await clickText('Fit');
// 13g. A sprite pointed at a frame slicing removed falls back to its colour.
await showTab('scene');
await sleep(700);
if (box) {
const fallback = await json(
`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`,
);
check(
'a sprite whose frame was sliced away falls back to its plain colour',
nearestColour(fallback) === 'plain',
JSON.stringify({ fallback, read: nearestColour(fallback) }),
);
}
}