Files
feuerwerk/tools/e2e/30-font.mjs
T
adminandClaude Opus 5.5 6b49a0bea7 Give each account its own folder of uploaded files to delete (#344)
Uploaded images, fonts and sounds belonged to nobody. Every account
saw every file in the pickers, and only an administrator could
delete one, through the API alone. Now every account uploads into a
folder of its own, sees its own files and nobody's, and can delete a
file of its own that no project uses.

- Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the
  folder is part of asset.file. A name without a folder belongs to
  nobody.
- The folder is a random 12-character name kept in users.json and
  given with the first upload. Renaming an account moves no file, and
  a released game's URLs carry no account name.
- The list shows the caller's own files and nobody's. An
  administrator sees all, each with owner and deletable.
- DELETE takes the name with its folder. It answers 403 unless the
  file is the caller's or the caller is an administrator, and 409
  while a project, snapshot or release of any account names it
  (FileUsage). The 409 names the caller's own projects only.
- The pickers have a Delete button. The first press asks, the second
  deletes, and a refusal shows the server's reason.
- A deleted account's folder stays and only administrators see it.
- tools/migrate-file-owners.php moved every used file into the folder
  of the account using it (a copy each for two accounts) and rewrote
  projects, snapshots and releases. A project's revision goes up, so
  an open editor gets a conflict instead of saving old names back.
- Font families are named after the hash alone, so a folder never
  ends up in a CSS family name.

Run on stein2 on 2026-10-07, backup in
/root/config-backup-20261007/data-before-344: 25 files used,
27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30
unused files stay nobody's. Afterwards all 600 references resolve to
a file in their owner's folder.

Found while building: the Delete button cut its new label to "Del…"
because a button does not measure itself again on a new text. The
picker now lays its button row out again.

Tests:
- PHP files.test.php (new): 46 checks, counter-check 3 red
- Unit core + editor: 220 files, 3403 tests
- e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:25:13 +02:00

440 lines
21 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 { edge, node } from '../lib/builders.mjs';
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;
/** How many pixels across the text a check reads. */
const SAMPLE = 120;
const RED_SHADER = {
id: 'ft-red',
type: 'shader',
name: 'Red glyphs',
props: {},
graph: {
nodes: [
node('ft-red-color', 'ShaderColor', 0, 0, { value: 0xe63c3c }),
node('ft-red-out', 'ShaderOutput', 260, 0),
],
edges: [edge('ft-red-e1', 'ft-red-color', 'color', 'ft-red-out', 'color')],
},
};
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]{12}\/[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. Parent and Shader (#169)
// say "None" as well, so the Font row's dropdown is the "None" at its height.
const fontRow = await json("__gm.inspectorField('Font', true)");
const nones = await evaluate(`(() => {
const out = [];
const walk = (node) => {
if (!node.visible) return;
if (node.text === 'None') out.push(Math.round(node.getBounds().y + node.getBounds().height / 2));
for (const child of node.children ?? []) walk(child);
};
walk(__gm.app.stage);
return out;
})()`);
check(
'a text has a Font row, and it reads None',
Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12),
JSON.stringify({ fontRow, nones }),
);
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. A text wears a shader like a sprite (#169), picked in its own Shader row. The graph paints
// every glyph pixel one red and keeps the text's alpha, so no white may survive it.
const overrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
await mutate(`
Object.assign(${overrides}, { font: ${JSON.stringify(fontId)} });
__gm.state.project.assets.push(${JSON.stringify(RED_SHADER)});`);
await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
/** A run of pixels across the middle of the text, where every glyph is cut. */
const rowAcross = async (view = 'objectRect') => {
const r = await json(`__gm.${view}(${JSON.stringify(TEXT_OBJECT)})`);
return r ? json(`__gm.pixelRow(${Math.round(r.x - SAMPLE / 2)}, ${Math.round(r.y)}, ${SAMPLE})`) : [];
};
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
// Red is counted by hue, not by the exact colour: drawn small, a glyph's pixels blend into the background.
const reddish = (hex) => {
const [r, g, b] = channels(hex);
return r > 0x60 && r > 2 * g && r > 2 * b;
};
const summary = (row) => ({
red: row.filter(reddish).length,
white: row.filter((hex) => channels(hex).every((c) => c > 0xd0)).length,
});
const white = summary(await rowAcross());
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
const shaderRow = await json("__gm.inspectorField('Shader', true)");
const noShader = await locate('None', true);
check(
'a text has a Shader row below its own, and it reads None',
Boolean(shaderRow) && Math.abs(noShader.y - shaderRow.y) < 12 && white.white > 0 && white.red === 0,
JSON.stringify({ shaderRow, noShader, white }),
);
await click(shaderRow.x, shaderRow.y);
await clickLast('Red glyphs (shader)');
const redInfo = await info();
const red = summary(await rowAcross());
check(
'picking a shader draws the text through it: its glyphs red, none of them white',
redInfo?.shader === RED_SHADER.id && red.red > 0 && red.white === 0,
JSON.stringify({ redInfo, red }),
);
await shot('30-font-text-shader');
// 30i. New content is rasterised anew, and the shader stays on it.
await mutate(`${overrides}.text = 'Handgloves Handgloves'`);
const longer = await info();
const redLonger = summary(await rowAcross());
check(
'changing the text keeps the shader on the new glyphs',
longer?.shader === RED_SHADER.id &&
longer.width > redInfo.width &&
redLonger.red > 0 &&
redLonger.white === 0,
JSON.stringify({ longer, redLonger }),
);
// 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own
// units. The graph fills the whole quad here, so the margin shows as red left of the text.
const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`;
const filled = (padding) =>
mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))});
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`);
// The red run on the row through the text: where the filled quad begins and ends on screen.
const redSpan = async () => {
const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
const from = Math.max(0, Math.round(r.x) - 600);
const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`);
const red = row.map(reddish);
return {
first: from + red.indexOf(true),
last: from + red.lastIndexOf(true),
unit: r.w / (await info()).width,
};
};
await evaluate('__gm.state.select(null)');
await filled(0);
const textTight = await redSpan();
await filled(6);
const textRoomy = await redSpan();
await shot('30-font-text-padding');
const reach = 6 * textTight.unit;
check(
'a padded shader draws six units past the text’s rectangle on either side, and none further',
textTight.last > textTight.first &&
Math.abs(textTight.first - textRoomy.first - reach) <= 2 &&
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textRoomy, reach }),
);
// 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the
// text's own units: six of them to the right, and the filled quad stands six further on.
await filled(0);
await mutate(`
const graph = ${shaderGraph};
graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))});
graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`);
const textMoved = await redSpan();
check(
'a Vertex output fed 6 draws the text six units to the right of where it stands',
Math.abs(textMoved.first - textTight.first - reach) <= 2 &&
Math.abs(textMoved.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textMoved, reach }),
);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id));
graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id));
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
// in the chained run, earlier steps leave pictures of their own at the camera.
await place('{ x: cam.x, y: cam.y, z: 1000 }');
await play();
await sleep(600);
const playedShaded = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
const playedPixels = await rowAcross('gameRect');
const playedRow = summary(playedPixels);
check(
'the running game draws the text through its shader as well',
playedShaded?.shader === RED_SHADER.id && playedRow.red > 0 && playedRow.white === 0,
JSON.stringify({
playedShaded,
playedRow,
rect: await json(`__gm.gameRect(${JSON.stringify(TEXT_OBJECT)})`),
scale: await json('__gm.gameScale()'),
playedPixels,
}),
);
await stop();
await place(`{ x: 0, y: ${LANE}, z: 0 }`);
// 30k. The font's Filter (#169 comment): Nearest rasterises a pixel per unit and magnifies without
// smoothing. At scale 4 a sharp glyph is blocks of four screen pixels, a smooth one blends
// its edges – so the shortest run of one colour inside the row tells them apart.
// Nothing selected: the outline of a selected text would cross the row with runs of its own.
await mutate(`Object.assign(${overrides}, { shader: '', text: 'Hg', fontSize: 16, scale: 4 })`);
await evaluate('__gm.state.select(null)');
// The scene's grid shows through where the text is transparent, in runs of its own, so everything
// darker than the glyphs counts as one background.
const shortestRun = (pixels) => {
const row = pixels.map((hex) => (channels(hex)[0] > 0x60 ? hex : 'background'));
const runs = [];
let length = 1;
for (let i = 1; i < row.length; i++) {
if (row[i] === row[i - 1]) length++;
else {
runs.push(length);
length = 1;
}
}
// The first and the last run are cut off by the ends of the row.
return Math.min(...runs.slice(1));
};
const smoothInfo = await info();
const smoothRow = await rowAcross();
const smoothRun = shortestRun(smoothRow);
await select({ kind: 'asset', assetId: fontId });
const filterRow = await json("__gm.inspectorField('Filter', true)");
const linear = await locate('Linear', true);
check(
'a font has a Filter row, Linear by default, and its text blends the edges of its glyphs',
Boolean(filterRow) && Math.abs(linear.y - filterRow.y) < 12 && smoothInfo?.smooth && smoothRun <= 2,
JSON.stringify({ filterRow, linear, smoothInfo, smoothRun, smoothRow }),
);
await click(filterRow.x, filterRow.y);
await clickLast('Nearest');
const filter = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).props.filter`,
);
const sharpInfo = await info();
const sharpRow = await rowAcross();
const sharpRun = shortestRun(sharpRow);
check(
'Nearest draws its text a pixel per unit, magnified in blocks',
filter === 'nearest' && sharpInfo?.smooth === false && sharpInfo.resolution === 1 && sharpRun >= 3,
JSON.stringify({ filter, sharpInfo, sharpRun, sharpRow }),
);
await shot('30-font-nearest');
// 30k-ii. …and its texels are hard: rasterised finer and cut at half coverage, so no glyph edge is
// left half covered – Chrome on Windows smooths canvas text sideways, whatever the filter.
// The fixture's face is no pixel font, so rasterised 1:1 its edges would be half covered here too.
check(
'Nearest leaves no half-covered texel in the text, the smooth face does',
smoothInfo?.partial > 0 && sharpInfo?.partial === 0,
JSON.stringify({ smooth: smoothInfo?.partial, sharp: sharpInfo?.partial }),
);
// 30l. 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(RED_SHADER.id)},
...${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')),
);
}