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>
217 lines
8.4 KiB
JavaScript
217 lines
8.4 KiB
JavaScript
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) }),
|
||
);
|
||
}
|
||
}
|