Files
feuerwerk/tools/e2e/25-animation.mjs
T
2026-09-17 12:53:56 +02:00

338 lines
14 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';
const RED = '#ff0000';
const SIZE = 40;
/**
* Animations (#97, #98, #99, #110, #102): a row is added through the dialog, keys are set, moved,
* banded, deleted, copied, pasted and duplicated on the timeline with real gestures, frames are
* inserted, and the preview above it is read at the playhead – as a model value, as a screen rectangle and as pixels. The subject is a container whose child
* is animated, since a row aiming inside the root is the case a single sprite cannot show. It
* removes everything it made.
*/
export async function animation(t) {
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText } = t;
const { undo, settle, sleep, waitUntil, find } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const sprite = structuredClone(p.assets.find((a) => a.id === 'crate'));
Object.assign(sprite, { id: 'an-sprite', name: 'Arm sprite' });
sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xff0000, texture: '', shader: '' };
const arm = structuredClone(scene.objects[0]);
Object.assign(arm, {
id: 'an-arm', assetId: 'an-sprite', name: 'Arm', parentId: '',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {},
});
p.assets.push(
sprite,
{ id: 'an-box', type: 'container', name: 'Dancer', props: { enabled: true }, objects: [arm] },
{ id: 'an-walk', type: 'animation', name: 'Walk', props: { fps: 10, playback: 'loop' }, root: 'an-box', tracks: [] },
);
__gm.state.select({ kind: 'asset', assetId: 'an-walk' });
`);
await openTab(t, 'animation');
await sleep(300);
check('the animation tab shows the animation', (await json('__gm.animation.current()')) === 'an-walk');
const walk = () => json("__gm.state.project.assets.find((a) => a.id === 'an-walk')");
const template = () =>
json("JSON.stringify(__gm.state.project.assets.find((a) => a.id === 'an-box').objects)");
const keysOf = async () => (await walk()).tracks[0]?.keys ?? [];
const templateBefore = await template();
// 25a. The dialog offers the root and what is placed inside it; a row aims at the child.
const add = await json('__gm.animation.addRowAt()');
await click(add.x, add.y);
const offered = await json('__gm.animation.rowDialog()');
check(
'the row dialog offers the root itself and the object placed inside it',
JSON.stringify(offered?.targets) === JSON.stringify(['Dancer (itself)', 'Arm']),
JSON.stringify(offered),
);
await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')");
await settle();
const armProperties = (await json('__gm.animation.rowDialog()')).properties.length;
const addButton = await find('Add row', true);
await click(addButton.x, addButton.y);
const row = (await walk()).tracks;
check(
'confirming adds one row aimed at the child, with no keys yet',
row.length === 1 && row[0].target === 'an-arm' && row[0].property === 'x' && row[0].keys.length === 0,
JSON.stringify(row),
);
const trackId = row[0]?.id;
await click(add.x, add.y);
await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')");
await settle();
const again = await json('__gm.animation.rowDialog()');
check(
'the same object and property is not offered a second time and cannot be confirmed',
again.properties.length === armProperties - 1 && again.ok === false,
JSON.stringify({ armProperties, again }),
);
const cancel = await find('Cancel', true);
await click(cancel.x, cancel.y);
check('cancelling adds nothing', (await walk()).tracks.length === 1);
// 25b. A double click sets a key with the value the row has there; the inspector edits the key.
const cell = (frame) => json(`__gm.animation.cellAt(${JSON.stringify(trackId)}, ${frame})`);
const c0 = await cell(0);
await doubleClick(c0.x, c0.y);
const c10 = await cell(10);
await doubleClick(c10.x, c10.y);
const doubled = await keysOf();
check(
'a double click sets a key holding the value in force there – the child stands at 0',
JSON.stringify(doubled) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 10, value: 0 },
]),
JSON.stringify(doubled),
);
const selection = (await t.state()).selection;
const valueField = await json("__gm.inspectorField('Value')");
check(
'the key just set is picked and the inspector shows its value',
selection?.kind === 'track' && selection.keys?.[0]?.frame === 10 && Boolean(valueField),
JSON.stringify({ selection, valueField }),
);
await click(valueField.x, valueField.y);
await press('a', { ctrl: true });
await typeText('100');
await press('Enter');
await settle();
const edited = await keysOf();
check(
'typing a value writes the key, and the container template stays as it was',
edited.find((k) => k.frame === 10)?.value === 100 && (await template()) === templateBefore,
JSON.stringify(edited),
);
const info = await json('__gm.animation.info()');
check(
'the readout counts to the last key: 11 frames, 1.1 s',
info === '1 row · 11 frames · 10 fps · 1.1 s',
info,
);
// 25c. The ruler scrubs, and the preview draws the child at the playhead. Framed at the far end,
// so both ends and the middle are on screen together.
const scrubTo = async (frame) => {
const p = await json(`__gm.animation.rulerAt(${frame})`);
await click(p.x, p.y);
await sleep(100);
};
const armId = await json("__gm.animation.preview.objectId('an-arm')");
const rectAt = async (frame) => {
await scrubTo(frame);
return json(`__gm.animation.preview.rect(${JSON.stringify(armId)})`);
};
await scrubTo(10);
const fit = await json('__gm.animation.preview.fitAt()');
await click(fit.x, fit.y);
await sleep(200);
const start = await rectAt(0);
const end = await rectAt(10);
const middle = await rectAt(5);
const playhead = (await json('__gm.animation.view()')).playhead;
const drawn = await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`);
const expectedX = start && end ? (start.x + end.x) / 2 : NaN;
check(
'clicking the ruler at 5 puts the playhead there, and the child is drawn half way at x 50',
playhead === 5 && drawn?.x === 50 && Math.abs(middle?.x - expectedX) <= 1 && end?.x - start?.x > SIZE,
JSON.stringify({ playhead, drawn: drawn?.x, start, middle, end }),
);
await shot('25-animation-preview');
if (!start || !middle || !end) return;
// Pixels: red all over the child where it is drawn, and nothing red where it stood at frame 0.
const grid = [];
for (const fx of [-0.3, 0, 0.3]) for (const fy of [-0.3, 0, 0.3]) grid.push([fx, fy]);
const samples = await json(
`[${grid.map(([fx, fy]) => `__gm.pixelAt(${Math.round(middle.x + fx * middle.w)}, ${Math.round(middle.y + fy * middle.h)})`).join(',')}]`,
);
const left = await json(`__gm.pixelAt(${Math.round(start.x)}, ${Math.round(start.y)})`);
check(
'the preview paints the child red at frame 5, and the spot it held at frame 0 is empty',
samples.every((c) => colourDistance(c, RED) < 30) && colourDistance(left, RED) > 150,
JSON.stringify({ samples, left }),
);
// 25d. Drag moves a key, landing on another replaces it; undo brings the replaced one back.
const c5 = await cell(5);
await doubleClick(c5.x, c5.y);
const withMiddle = await keysOf();
const from = await cell(5);
const to = await cell(10);
await drag(from.x, from.y, to.x, to.y);
const landed = await keysOf();
check(
'a key dragged onto another replaces it and keeps its own value',
withMiddle.find((k) => k.frame === 5)?.value === 50 &&
JSON.stringify(landed) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 10, value: 50 },
]),
JSON.stringify({ withMiddle, landed }),
);
await undo();
check(
'undo takes the drag back, the replaced key included',
JSON.stringify(await keysOf()) === JSON.stringify(withMiddle),
JSON.stringify(await keysOf()),
);
// 25e. A band from the empty space below the row over all three keys, then Delete.
const bandFrom = { x: (await cell(14)).x, y: c0.y + 40 };
const bandTo = { x: c0.x - 12, y: c0.y - 11 };
await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y);
const banded = (await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame);
await press('Delete');
const deleted = await keysOf();
check(
'the band picks every key it spans, and Delete removes them all but keeps the row',
banded.join() === '0,5,10' && deleted.length === 0 && (await walk()).tracks.length === 1,
JSON.stringify({ banded, deleted }),
);
await undo();
check(
'one undo brings all three keys back',
JSON.stringify(await keysOf()) === JSON.stringify(withMiddle),
JSON.stringify(await keysOf()),
);
// 25f. Once (hold) keeps the last value; once (reset) puts the starting value back at the end.
const playThrough = async (playback) => {
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = '${playback}'`);
await scrubTo(0);
const play = await json('__gm.animation.playAt()');
await click(play.x, play.y);
await waitUntil('!__gm.animation.view().playing', { timeout: 5000, label: `the ${playback} pass ended` });
await sleep(100);
return {
playhead: (await json('__gm.animation.view()')).playhead,
x: (await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`))?.x,
button: (await json('__gm.animation.playAt()')).text,
};
};
const held = await playThrough('once');
const reset = await playThrough('once-reset');
check(
'a once pass ends holding the last key, a once-reset pass ends showing the starting value',
held.x === 100 && reset.x === 0 && reset.playhead >= 10 && reset.button === 'Play',
JSON.stringify({ held, reset }),
);
// 25g. Copy and paste (#102): two keys banded, Ctrl+C, the playhead to 12, Ctrl+V – they land
// relative to the playhead, keeping their distance, and come back picked. Each step undone.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`);
const three = JSON.stringify(withMiddle);
const pickedFrames = async () =>
(await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame);
const c3 = await cell(3);
await drag((await cell(14)).x, c0.y + 40, c3.x, c0.y - 11);
const copied = await pickedFrames();
await press('c', { ctrl: true });
const clip = await json('__gm.animation.clipboard()');
await scrubTo(12);
await press('v', { ctrl: true });
const pasted = await keysOf();
const pastedPick = await pickedFrames();
check(
'Ctrl+C and Ctrl+V paste the banded keys relative to the playhead and pick them',
copied.join() === '5,10' &&
JSON.stringify(clip?.rows?.[0]?.keys?.map((k) => k.offset)) === '[0,5]' &&
JSON.stringify(pasted) ===
JSON.stringify([...withMiddle, { frame: 12, value: 50 }, { frame: 17, value: 100 }]) &&
pastedPick.join() === '12,17',
JSON.stringify({ copied, clip, pasted, pastedPick }),
);
await undo();
check(
'one undo takes the paste back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// Ctrl+drag duplicates: the key at 5 stays, a copy lands on 7.
const k5 = await cell(5);
const k7 = await cell(7);
await click(k5.x, k5.y);
await drag(k5.x, k5.y, k7.x, k7.y, { modifiers: { ctrl: true } });
const duplicated = await keysOf();
check(
'Ctrl+drag leaves the key where it was and drops a copy where the pointer lets go',
JSON.stringify(duplicated) ===
JSON.stringify([withMiddle[0], withMiddle[1], { frame: 7, value: 50 }, withMiddle[2]]),
JSON.stringify(duplicated),
);
await undo();
// Right-click → Insert frames… at the playhead: every key from there on moves by the count.
await scrubTo(5);
const inCells = await cell(2);
await click(inCells.x, inCells.y + 40, 'right');
const insertItem = await find('Insert frames at 5…', true);
await click(insertItem.x, insertItem.y);
await evaluate('__gm.animation.framesDialogCount(3)');
await settle();
const dialog = await json('__gm.animation.framesDialog()');
const confirm = await json('__gm.animation.framesDialogConfirm()');
await click(confirm.x, confirm.y);
const inserted = await keysOf();
check(
'the menu inserts 3 frames at the playhead: the keys on 5 and 10 move to 8 and 13, the one on 0 stays',
dialog?.summary === 'Moves 2 keys 3 frames later.' &&
JSON.stringify(inserted.map((k) => k.frame)) === '[0,8,13]' &&
(await json('__gm.animation.framesDialog()')) === null,
JSON.stringify({ dialog, inserted }),
);
await undo();
check(
'undo takes the inserted frames back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// 25h. Undo all the way: the key at 5, the value edit, the keys at 10 and 0, then the row. Exactly
// that many – one more would take back a command of an earlier step, so the count is checked
// on the way: after four the row is still there, empty.
await focusTimeline();
for (let i = 0; i < 4; i++) await undo();
const emptied = await walk();
await undo();
const undone = await walk();
check(
'undo takes back the keys and the value edit one by one, and the row last',
emptied.tracks.length === 1 &&
emptied.tracks[0].keys.length === 0 &&
undone.tracks.length === 0 &&
(await template()) === templateBefore,
JSON.stringify({ emptied: emptied.tracks, undone: undone.tracks }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));
__gm.state.select(null);
`);
const leftOver = await json(
"__gm.state.project.assets.filter((a) => ['an-walk', 'an-box', 'an-sprite'].includes(a.id)).length",
);
check('the step leaves the fixture as it found it', leftOver === 0, String(leftOver));
/** Puts the keyboard on the timeline, so Ctrl+Z is not taken by a text field. */
async function focusTimeline() {
const header = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`);
await click(header.x, header.y);
}
}