Files
feuerwerk/tools/e2e/30-font.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

222 lines
10 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 { LANES, pickAssetType } from './lib/scaffold.mjs';
/**
* Font assets (#74): uploaded like a picture – picked from what the server holds through "+ → Font",
* or dropped onto the editor – and worn by a text object through its Font row. No font means the
* editor's own typeface, which is also what a reference that no longer resolves falls back to.
*
* The seed puts two DejaVu faces on the server without making assets of them: this step makes them.
* Everything lives in its own lane (`__gm.lookAt`), because symbols draw above objects and the
* origin is crowded by what the earlier steps left behind.
*/
const LANE = LANES.font;
const TEXT_ASSET = 'ft-text';
const TEXT_OBJECT = 'ft-obj';
/** The two files the seed uploads, told apart by their exact size rather than by their name. */
const SERIF_BYTES = 380660;
const MONO_BYTES = 343140;
export async function font(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } =
t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file }))`);
const info = () => json(`__gm.textInfo(${JSON.stringify(TEXT_OBJECT)})`);
/** The overlay is drawn last, so a menu entry wins over a row of the asset list saying the same. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
};
/** A face arrives over the network, so what it changes is awaited rather than slept for. */
const waitFor = async (read, done, tries = 20) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 30a. What the server holds, straight from the API: the two files the seed put there.
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=fonts').then((r) => r.json())`);
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
const mono = stored.find((item) => item.bytes === MONO_BYTES);
check(
'the server holds the two seeded typefaces, named by their content',
Boolean(serif && mono) && /^[a-f0-9]{64}\.ttf$/.test(serif.file),
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
);
if (!serif || !mono) return;
// The hash says nothing, so the name the file was uploaded under is kept beside it (#83).
check(
'the listing carries the name each file was uploaded under',
serif.name === 'DejaVuSerif.ttf' && mono.name === 'DejaVuSansMono.ttf',
JSON.stringify({ serif: serif.name, mono: mono.name }),
);
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
await evaluate('__gm.console.clear()');
await clickText('+');
await pickAssetType(t, 'Font');
await sleep(500);
const picker = await json('__gm.fontPicker()');
check(
'the + menu opens the font picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
JSON.stringify(picker),
);
// A row the project does not wear yet is captioned by the file's own name (#83); without it
// there was nothing to read but its byte count.
check(
'a row nobody wears yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(serif.file)] === 'DejaVuSerif.ttf',
JSON.stringify(picker.captions),
);
await shot('30-font-picker');
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
// about the latter, and the warning is the only sign that the rows are built the wrong way.
const logged = await json('__gm.console.lines()');
check(
'building the rows draws no complaint from Pixi',
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
JSON.stringify(logged.slice(0, 3)),
);
const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`);
await click(row.x, row.y);
await clickText('Use font');
const fonts = await assetsOfType('font');
check(
'picking a row makes a font asset of that file, named after the file rather than "Font"',
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'DejaVuSerif',
JSON.stringify(fonts),
);
if (fonts.length !== 1) return;
const fontId = fonts[0].id;
// Renamed away from "Font": the inspector row is called that too, and a lookup by text would
// find the asset's row in the panel instead of the row being tested.
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).name = 'Bookish'`);
// 30c. A text in its own lane, drawn in the editor's own typeface while it wears no font.
await mutate(`
const project = __gm.state.project;
project.assets.push({ id: ${JSON.stringify(TEXT_ASSET)}, type: 'text', name: 'Title',
props: { enabled: true, scale: 1, rotation: 0, mirrorX: false, mirrorY: false,
text: 'Handgloves', font: '', fontSize: 48, color: 0xffffff, align: 'center' } });
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(TEXT_OBJECT)}, assetId: ${JSON.stringify(TEXT_ASSET)},
name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`);
const plain = await info();
check(
'a text wearing no font is drawn in the editor’s own typeface',
Boolean(plain) && !plain.family.startsWith('gm-font-') && plain.width > 0,
JSON.stringify(plain),
);
if (!plain) return;
// 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object.
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
// The label is no anchor on its own: the assets panel has a row per font. The dropdown of the
// Font row is the last "None" in the window – Parent says it above, nothing says it below.
const fontRow = await json("__gm.inspectorField('Font', true)");
const none = await locate('None', true);
check(
'a text has a Font row, and it reads None',
Boolean(fontRow) && Math.abs(none.y - fontRow.y) < 12,
JSON.stringify({ fontRow, none }),
);
await click(fontRow.x, fontRow.y);
await clickLast('Bookish (font)');
const chosen = await json(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`,
);
const worn = await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
check(
'picking it in the Font row redraws the text in that face, and it reflows',
chosen === fontId && Boolean(worn) && worn.width !== plain.width,
JSON.stringify({ chosen, plain, worn }),
);
await shot('30-font-scene');
// The asset's own inspector is where a font is looked at; it has nothing else to show.
await select({ kind: 'asset', assetId: fontId });
await shot('30-font-asset');
// 30e. The game draws through the same path, so the face is in play as well. The camera frames
// the origin, so the text has to leave its lane for as long as the game runs.
const place = (expression) =>
mutate(`
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
const text = scene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)});
Object.assign(text.position, ${expression});`);
await place('{ x: cam.x, y: cam.y }');
await play();
await sleep(600);
const played = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
check(
'the running game draws the text in the same face',
Boolean(played?.family.startsWith('gm-font-')) && played.family === worn?.family,
JSON.stringify(played),
);
await stop();
await place(`{ x: 0, y: ${LANE} }`);
// 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing.
await mutate(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font = 'no-such-asset'`,
);
const dangling = await waitFor(info, (value) => !value?.family.startsWith('gm-font-'));
check(
'a dangling font id falls back to the editor’s own typeface, at the width from before',
dangling?.family === plain.family && dangling?.width === plain.width,
JSON.stringify(dangling),
);
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(`
(async () => {
const bytes = await fetch('/game-maker/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
const data = new DataTransfer();
data.items.add(file);
const canvas = document.querySelector('canvas');
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
return file.size;
})()`);
await waitFor(
() => assetsOfType('font'),
(list) => list.length === 2,
);
const both = await assetsOfType('font');
check(
'a dropped typeface is uploaded and becomes an asset named after the file',
dropped === MONO_BYTES &&
both.length === 2 &&
both.some((a) => a.file === mono.file && a.name === 'DejaVuSansMono'),
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 30h. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => o.id !== ${JSON.stringify(TEXT_OBJECT)});
__gm.state.select(null);
__gm.lookAt(0, 0);`);
check(
'the step leaves nothing of its own behind',
(await assetsOfType('font')).length === 0 && (await info()) === null,
JSON.stringify(await assetsOfType('font')),
);
}