Files
feuerwerk/tools/e2e/29-transform.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

255 lines
11 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 { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
import { LANES } from './lib/scaffold.mjs';
/**
* Scale, rotation and mirror (#76): rows of every drawn object, cascading down the hierarchy like
* a position. Read in the pixels of the scene view, in where a child lands under a turned parent,
* in a drag that goes back through the parent's frame, and in a turned collider stopping a mover.
* Everything lives in its own lane, looked at through `__gm.lookAt`: the origin is crowded by
* what the earlier steps left, and symbols draw above objects.
*/
const RED = '#ff0000';
const SPRITE = 'tf-sprite';
const BOX = 'otf';
const CHILD = 'otf-child';
const CAMERA = 'otf-cam';
const RIG = ['otf-wall', 'otf-wallc', 'otf-body', 'otf-bodyc', 'otf-logic'];
const LANE = LANES.transform;
export async function transform(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
drag,
undo,
press,
holdKey,
object,
entity,
stop,
shot,
showTab,
} = t;
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`);
const screen = (id) => json(`__gm.objectScreen(${JSON.stringify(id)})`);
const override = (id, patch) =>
mutate(
`Object.assign(__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)}).overrides, ${JSON.stringify(patch)})`,
);
const spriteProps = (patch) =>
mutate(
`Object.assign(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SPRITE)}).props, ${JSON.stringify(patch)})`,
);
const selectObject = async (id) => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id });
};
const remove = (ids) =>
mutate(`
const gone = new Set(${JSON.stringify(ids)});
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !gone.has(o.id))`);
await press('Escape');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 29a. A plain red 100×40 sprite in its lane, and a second camera to read the inspector off;
// a sprite object has the transform rows, a camera object has not.
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
sprite.id = ${JSON.stringify(SPRITE)};
sprite.name = 'Turned';
sprite.props = { ...sprite.props, width: 100, height: 40, color: 0xff0000, texture: '', shader: '' };
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
scene.objects.push(
make(${JSON.stringify(BOX)}, ${JSON.stringify(SPRITE)}, '', { x: 0, y: ${LANE}, z: 960 }),
make(${JSON.stringify(CAMERA)}, 'cam', '', { x: 0, y: ${LANE - 400}, z: 0 }, { enabled: false }),
);
__gm.lookAt(0, ${LANE});
__gm.state.select(null)`);
await sleep(500);
await selectObject(BOX);
const rows = {
title: await t.has('TRANSFORM'),
scale: await field('Scale'),
rotation: await field('Rotation'),
mirrorX: await field('Mirror X'),
mirrorY: await field('Mirror Y'),
};
check(
'a sprite object has a TRANSFORM section with Scale, Rotation, Mirror X and Mirror Y',
rows.title && rows.scale && rows.rotation && rows.mirrorX && rows.mirrorY,
JSON.stringify(rows),
);
await selectObject(CAMERA);
check(
'a camera object has no transform rows',
(await field('Rotation')) === null && (await field('Scale')) === null && !(await t.has('TRANSFORM')),
);
await selectObject(BOX);
// 29b. Pixels: world units from the sprite's centre, at the view's scale.
const base = await rect(BOX);
check(
'the sprite is on screen',
Boolean(base) && base.w > 8,
JSON.stringify({ base, viewport: await json('__gm.viewportRect()') }),
);
if (!base) return;
const s = base.w / 100;
const px = (dx, dy) => json(`__gm.pixelAt(${Math.round(base.x + dx * s)}, ${Math.round(base.y + dy * s)})`);
const isRed = (c) => colourDistance(c, RED) < 12;
const upright = { right: await px(40, 0), below: await px(0, 40) };
check(
'upright: red 40 to the right of the centre, not red 40 below (the box is 40 tall)',
isRed(upright.right) && !isRed(upright.below),
JSON.stringify(upright),
);
// 29c. Rotation 90: the box stands up – its frame swaps sides, the pixels move with it.
await override(BOX, { rotation: 90 });
const turned = await rect(BOX);
const turnedPx = { right: await px(40, 0), below: await px(0, 40) };
check(
'rotation 90 turns the box: frame 40 wide and 100 tall, red below the centre, not to the right',
Math.abs(turned.w - 40 * s) < 2 &&
Math.abs(turned.h - 100 * s) < 2 &&
isRed(turnedPx.below) &&
!isRed(turnedPx.right),
JSON.stringify({ turned, turnedPx }),
);
// 29d. Scale 2 on top: twice the frame, red where the upright box would have ended long ago.
await override(BOX, { scale: 2 });
const grown = await rect(BOX);
const far = await px(0, 90);
check(
'scale 2 doubles the turned box: frame 80 by 200, red 90 below the centre',
Math.abs(grown.w - 80 * s) < 2 && Math.abs(grown.h - 200 * s) < 2 && isRed(far),
JSON.stringify({ grown, far }),
);
await shot('29-turned-scaled');
// 29e. Mirror, read on a two-colour picture: red left, green right; Mirror X swaps them, Mirror Y
// does not (the halves are side by side).
await override(BOX, { rotation: 0, scale: 1 });
await evaluate(PASTE_HALVES);
await sleep(2500);
const picture = await json(FIND_HALVES);
check('a two-colour picture is pasted in for the mirror checks', Boolean(picture), JSON.stringify(picture));
await evaluate('__gm.state.select(null)');
await spriteProps({ texture: picture?.id ?? '', color: 0xffffff });
await sleep(600);
const plain = { left: await px(-30, 0), right: await px(30, 0) };
check(
'the picture is on: red left, green right',
colourDistance(plain.left, HALVES_LEFT) < 12 && colourDistance(plain.right, HALVES_RIGHT) < 12,
JSON.stringify(plain),
);
await override(BOX, { mirrorX: true });
const mirrored = { left: await px(-30, 0), right: await px(30, 0) };
check(
'Mirror X swaps the halves: green left, red right',
colourDistance(mirrored.left, HALVES_RIGHT) < 12 && colourDistance(mirrored.right, HALVES_LEFT) < 12,
JSON.stringify(mirrored),
);
await override(BOX, { mirrorY: true });
const both = { left: await px(-30, 0), right: await px(30, 0) };
check(
'Mirror Y on top leaves the halves where Mirror X put them',
colourDistance(both.left, HALVES_RIGHT) < 12 && colourDistance(both.right, HALVES_LEFT) < 12,
JSON.stringify(both),
);
await override(BOX, { mirrorX: false, mirrorY: false });
await spriteProps({ texture: '', color: 0xff0000 });
// 29f. A child 100 to the right of its parent: once the parent turns a quarter, the child hangs
// 100 below it – and is drawn turned itself.
await mutate(`
const scene = __gm.state.activeScene;
scene.objects.push({ id: ${JSON.stringify(CHILD)}, assetId: ${JSON.stringify(SPRITE)}, name: '', position: { x: 100, y: 0, z: 1 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: ${JSON.stringify(BOX)}, instanceEdits: {} });`);
const flat = { parent: await screen(BOX), child: await screen(CHILD) };
await override(BOX, { rotation: 90 });
const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) };
check(
'a child follows its parent’s turn: 100 to the right becomes 100 below, drawn turned',
Math.abs(flat.child.x - (flat.parent.x + 100 * s)) < 2 &&
Math.abs(flat.child.y - flat.parent.y) < 2 &&
Math.abs(hung.child.x - hung.parent.x) < 2 &&
Math.abs(hung.child.y - (hung.parent.y + 100 * s)) < 2 &&
Math.abs(hung.childRect.w - 40 * s) < 2 &&
Math.abs(hung.childRect.h - 100 * s) < 2,
JSON.stringify({ flat, hung, s }),
);
// 29g. Dragging the child 30 screen pixels down moves it along the parent's x axis – which now
// points down – so its local x grows by 30 / scale and y stays.
await drag(hung.child.x, hung.child.y, hung.child.x, hung.child.y + 30, { steps: 8 });
const dragged = await object(CHILD);
check(
'a drag under a turned parent lands in the parent’s frame: local x + 30 / scale, y unchanged',
Math.abs(dragged.position.x - (100 + 30 / s)) <= 1 && Math.abs(dragged.position.y) <= 1,
JSON.stringify({ position: dragged.position, expected: 100 + 30 / s }),
);
await undo();
check('undo puts the child back', (await object(CHILD)).position.x === 100);
await selectObject(CHILD);
const gizmos = await json('__gm.gizmos()');
check(
'the turned child is outlined like any selection',
gizmos.some((g) => g.id === CHILD && g.primary && g.alpha === 1),
JSON.stringify(gizmos),
);
await shot('29-turned-child');
// 29h. In the game a turned collider blocks where it really is. A crate body with the fixture's
// Mover walks right into a wall whose 140-wide collider stands on end (60 wide): it parks
// flush at −80 – upright, the wall would stop it at −120 (see 8c).
await mutate(`
const scene = __gm.state.activeScene;
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
scene.objects.push(
make('otf-wall', 'crate', '', { x: 0, y: ${LANE + 300}, z: 0 }, { rotation: 90 }),
make('otf-wallc', 'collider', 'otf-wall', { x: 0, y: 0, z: 0 }, { width: 140 }),
make('otf-body', 'crate', '', { x: -200, y: ${LANE + 300}, z: 0 }),
make('otf-bodyc', 'collider', 'otf-body', { x: 0, y: 0, z: 0 }),
make('otf-logic', 'mover', 'otf-body', { x: 0, y: 0, z: 0 }),
);
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await holdKey('ArrowRight', 1000);
const body = await entity('otf-body');
await stop();
check(
'a turned collider stops the mover where the turned box is: flush at x = −80',
body && Math.abs(body.x - -80) < 0.5,
`world x=${body?.x}`,
);
await remove(RIG);
// 29i. Cleanup, and the view back where the earlier steps left the scene.
await remove([BOX, CHILD, CAMERA]);
await mutate(`
const project = __gm.state.project;
const gone = new Set([${JSON.stringify(SPRITE)}, ${JSON.stringify(picture?.id ?? '')}]);
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.lookAt(0, 0)`);
await evaluate('__gm.state.select(null)');
check(
'the step leaves nothing behind',
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(SPRITE)})`)) === false &&
(await json(`__gm.state.activeScene.objects.some((o) => o.id.startsWith('otf'))`)) === false &&
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) ===
false,
);
}