Files
feuerwerk/tools/e2e/15-aspect.mjs
T
rootandClaude Opus 5 4568c6581b Add pixel-size fit and drag-to-reorder in the hierarchy
The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.

Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:20:46 +02:00

166 lines
7.3 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.
/**
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
* picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears
* no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks
* below distinguish.
*
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
* different shape, which is what makes "sheet or frame?" a question the numbers can answer.
*/
/** The rows are read as numbers, so a float from a division needs a tolerance. */
const near = (value, expected) => Math.abs(value - expected) < 0.01;
const spriteProp = (key) => `__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.${key}`;
export async function aspect(t) {
const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t;
/** Whether the size button of a row is there at all, in either its plain or its menu form. */
const sizeButton = async (glyph) =>
(await json(`__gm.locate('${glyph}')`))
? 'plain'
: (await json(`__gm.locate('${glyph} ▾')`))
? 'menu'
: '';
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
check('the imported sheet is still there', sheet !== '', sheet);
// 26. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
// Edited as an asset: one base value per row instead of an override, so the numbers below
// say what they mean.
await mutate(`
const props = __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props;
props.texture = '';
props.width = 100;
props.height = 100`);
await select({ kind: 'asset', assetId: 'tex-sprite' });
await sleep(600);
check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')")));
check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
await shot('40-aspect-without-a-texture');
// 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
// so both rows offer two things and show the menu form.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await sleep(500);
check(
'a texture brings both buttons out, offering fit and pixel size',
(await sizeButton('↔')) === 'menu' && (await sizeButton('↕')) === 'menu',
);
await shot('41-aspect-buttons');
// 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
await sleep(500);
const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↔ sets the width from the height and the sheet ratio',
near(fitted.w, (100 * 64) / 48) && fitted.h === 100,
JSON.stringify(fitted),
);
check(
'and the fit drops out of the menu once the sprite is proportional',
(await sizeButton('↔')) === 'plain' && (await sizeButton('↕')) === 'plain',
);
await shot('42-aspect-fitted');
// 28a. One command, so it is one undo step – and the menu form comes back with the old shape.
await undo();
await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
check('and the buttons return with it', (await sizeButton('↔')) === 'menu');
await t.redo();
await sleep(500);
// 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// the whole button goes because there is nothing left to offer.
await clickText('↔');
await sleep(500);
const pixels = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'the pixel-size action sets both sides to the picture',
pixels.w === 64 && pixels.h === 48,
JSON.stringify(pixels),
);
check(
'and nothing is left to offer, so no button is drawn',
(await sizeButton('↔')) === '' && (await sizeButton('↕')) === '',
);
await undo();
await sleep(500);
check(
'both sides come back in one undo step',
near(await json(spriteProp('width')), (100 * 64) / 48) && (await json(spriteProp('height'))) === 100,
);
await t.redo();
await sleep(500);
// 29. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
// the sheet's 64 x 48, so the two ratios cannot be confused for one another.
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet)});
asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 });
${spriteProp('texture')} = 'aspect-frame'`);
await sleep(500);
check('a frame of another shape brings the buttons back', (await sizeButton('↕')) === 'menu');
const before = await json(spriteProp('width'));
await clickText('↕ ▾');
await sleep(400);
await clickText('Fit to 40 × 10');
await sleep(500);
const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↕ sets the height from the frame ratio, not the sheet it belongs to',
near(framed.h, (before * 10) / 40) && framed.w === before,
JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }),
);
check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
// 30. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400);
check('the object row offers the fit as well', (await sizeButton('↔')) === 'menu');
// The crowded case: an overridden, out-of-proportion row carries ↔ ▾ next to ↑ and ↺.
check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0);
await shot('43-aspect-beside-the-override-buttons');
await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
await sleep(500);
const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'fitting a scene object writes an override, leaving the asset alone',
near(override.width, (90 * 64) / 48) && near(await json(spriteProp('width')), framed.w),
JSON.stringify({ override, asset: await json(spriteProp('width')) }),
);
await shot('44-aspect-on-an-object');
// 30a. The pixel size on an object writes both overrides, and as one undo step.
await clickText('↔');
await sleep(500);
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'the pixel size writes both overrides at once',
sized.width === 64 && sized.height === 48,
JSON.stringify(sized),
);
await undo();
await sleep(500);
const back = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'and one undo takes both back',
near(back.width, (90 * 64) / 48) && back.height === 90,
JSON.stringify(back),
);
}