Files
feuerwerk/tools/e2e/13-frames.mjs
T
rootandClaude Opus 5 4feb4c2cf6 A step opens a tab the way a person has to, through the menu when it is hidden
CI run 35 was red: the suite died after step 15 with `widget with text
"Container" not found`. Nothing in the editor broke — the tenth tab (#73) pushed
Container behind the `»` button at the test window's 1280 px, exactly the case
#84 was built for, and three steps still clicked the tab name directly.

openTab(t, tab) in tools/e2e/lib/scaffold.mjs asks tabRow() whether the tab is
hidden and goes through the menu when it is, so the fix lives in one place rather
than in every step that opens a tab. The LABELS record moves there as TAB_LABELS,
since both the helper and 14-overflow need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:03:40 +02:00

217 lines
8.4 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 } = 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');
await sleep(400);
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();
await sleep(400);
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();
await sleep(400);
// 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 evaluate("__gm.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 evaluate("__gm.showTab('texture')");
await sleep(400);
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 evaluate("__gm.showTab('texture')");
await sleep(400);
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');
await sleep(400);
// 13g. A sprite pointed at a frame slicing removed falls back to its colour.
await evaluate("__gm.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) }),
);
}
}