Files
feuerwerk/tools/e2e/25-animation.mjs
T
adminandClaude Opus 5.5 480db9c4e4
game-maker / gate (push) Successful in 4m28s
game-maker / suite (push) Successful in 18m13s
Scroll the rows of the animation timeline up and down (#413)
An animation with more rows than the panel is tall cut the bottom rows
off. There was no way to reach them, their fields or their keys.

- The view has a new `scrollY`. Rows scroll up under the ruler, which
  stays where it is together with the tab head.
- The wheel over the header column scrolls the rows. Over the cells it
  still zooms, and `Shift`+wheel still scrolls across.
- A scrollbar at the right edge shows up only when the rows do not
  fit. Its thumb can be dragged, a click beside it jumps there.
- The middle button pans both ways. `Shift`+drag stays across only.
- A row picked or added out of view is scrolled just far enough to be
  seen whole. Value fields out of view are not drawn.
- The tab keeps its scroll like its zoom when switching.

Found on the way: `zoomCells` built a new view from `panX` and
`cellWidth` only, so every zoom would have dropped the scroll. The
type check caught it, and a unit test now holds it.

Tests:
- tsc, eslint, Prettier clean
- Unit timeline-view: 20 green, 6 new. Bending `rowAt` to ignore the
  scroll or `revealRow` to do nothing turns exactly those tests red.
- e2e 25 on melektaus-atx: ALL PASSED, 37 checks (25j, 25j-ii new).
  32 rows, the wheel brings the last field into view and typing keys
  77 there. Dragging the thumb up goes back to 0.
- Gate on melektaus-atx: green, 305 files, 4730 tests.
- Not checked in the browser: the middle button, revealing a picked
  row, the wheel right over a value field.

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

564 lines
24 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, wheel } = 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 armOffered = (await json('__gm.animation.rowDialog()')).properties;
const armProperties = armOffered.length;
check(
'the position rows come first, Z (the draw order) after X and Y',
JSON.stringify(armOffered.slice(0, 3)) === JSON.stringify(['X', 'Y', 'Z']),
JSON.stringify(armOffered),
);
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,
);
// 25b-ii. Easing is two fields: the curve, and for a curve its direction. Each pick is a step,
// all three are undone here so 25h counts only its own.
const easingOf = async () => (await keysOf()).find((k) => k.frame === 10)?.interpolation ?? 'linear';
const pick = async (field, label) => {
const row = await json(`__gm.inspectorField(${JSON.stringify(field)})`);
await click(row.x, row.y);
await sleep(100);
const rows = await json('__gm.menuRows()');
const entry = await t.locate(label, true);
await click(entry.x, entry.y);
await settle();
return rows;
};
const shapes = await pick('Easing', 'Cubic');
const cubicDirection = await json("__gm.inspectorField('Direction')");
check(
'the Easing field lists every curve, and a curve brings the Direction field, eased both ways',
JSON.stringify(shapes) ===
JSON.stringify([
'Linear',
'Step',
'Sine',
'Quad',
'Cubic',
'Quart',
'Quint',
'Expo',
'Circ',
'Back',
'Elastic',
'Bounce',
'Curve',
]) &&
(await easingOf()) === 'cubic-in-out' &&
Boolean(cubicDirection),
JSON.stringify({ shapes, easing: await easingOf(), cubicDirection }),
);
const directions = await pick('Direction', 'Out');
await shot('25-animation-easing');
const outward = await easingOf();
await pick('Easing', 'Back');
const back = await easingOf();
check(
'Direction offers In, Out and In-out, and a new curve keeps the direction picked',
JSON.stringify(directions) === JSON.stringify(['In', 'Out', 'In-out']) &&
outward === 'cubic-out' &&
back === 'back-out',
JSON.stringify({ directions, outward, back }),
);
await pick('Easing', 'Linear');
const linearDirection = await json("__gm.inspectorField('Direction')");
const linear = await easingOf();
await focusTimeline();
await undo();
const backAgain = await easingOf();
for (let i = 0; i < 3; i++) await undo();
const easingUndone = await easingOf();
const keptValue = (await keysOf()).find((k) => k.frame === 10)?.value;
check(
'linear drops the Direction field and the stored easing, and undo takes the four picks back one by one',
!linearDirection &&
linear === 'linear' &&
backAgain === 'back-out' &&
easingUndone === 'linear' &&
keptValue === 100,
JSON.stringify({ linearDirection, linear, backAgain, easingUndone, keptValue }),
);
// 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()),
);
// Right-click on the row's header → Delete row: the row goes, the animation stays picked; undo.
const rowHeader = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`);
await click(rowHeader.x, rowHeader.y, 'right');
const deleteItem = await find('Delete row', true);
// A destructive entry is red text on the plain menu, not a filled red row.
const menuRows = await json('__gm.menuRows()');
const looks = await json('__gm.menuLooks()');
const danger = await json(`__gm.themeColor('danger')`);
const plain = await json(`__gm.themeColor('text')`);
const deleteLook = looks[menuRows.indexOf('Delete row')];
const insertLook = looks[menuRows.findIndex((row) => row?.startsWith('Insert frames'))];
check(
'Delete row is drawn in red text without a red fill, a plain entry in the normal text colour',
deleteLook?.text === danger && deleteLook.fill !== danger && insertLook?.text === plain,
JSON.stringify({ deleteLook, insertLook, danger, plain }),
);
await click(deleteItem.x, deleteItem.y);
const rowsGone = (await walk()).tracks.length;
const afterDelete = await json('__gm.state.selection');
await undo();
check(
'the menu on a row header deletes that row, and undo brings it back with its keys',
rowsGone === 0 &&
afterDelete?.kind === 'asset' &&
(await walk()).tracks.length === 1 &&
JSON.stringify(await keysOf()) === three,
JSON.stringify({ rowsGone, afterDelete, keys: 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 }),
);
// 25i. The header (#408): rows grouped under their object in the order of the hierarchy, each
// object's rows in the order of its properties, the value at the playhead in the row, and
// a value typed there keys it at the playhead.
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'an-walk').tracks = [
{ id: 'an-r1', target: 'an-arm', property: 'color', keys: [] },
{ id: 'an-r2', target: '', property: 'enabled', keys: [] },
{ id: 'an-r3', target: 'an-arm', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] },
];
`);
await evaluate('__gm.animation.scrub(4)');
await sleep(150);
const lines = await json('__gm.animation.lines()');
const shape = lines.map((l) => (l.kind === 'group' ? l.text : ` ${l.text}=${JSON.stringify(l.value)}`));
check(
'the header groups the rows under Dancer and Arm, X before Color, each with its value at frame 4',
JSON.stringify(shape) ===
JSON.stringify(['Dancer', ' Enabled=true', 'Arm', ' X=40', ` Color=${0xff0000}`]) &&
lines[1].indent > lines[0].indent,
JSON.stringify(lines),
);
await shot('20-grouped-header');
const xField = await json("__gm.animation.valueAt('an-r3')");
await click(xField.x, xField.y);
await typeText('55');
await press('Enter');
await sleep(150);
const typed = (await walk()).tracks.find((r) => r.id === 'an-r3').keys;
const typedPick = await json('__gm.state.selectedKeys');
check(
'a value typed into the row keys it at the playhead and picks that key',
JSON.stringify(typed) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 4, value: 55 },
{ frame: 10, value: 100 },
]) && JSON.stringify(typedPick) === JSON.stringify([{ trackId: 'an-r3', frame: 4 }]),
JSON.stringify({ typed, typedPick }),
);
// Pressing the ruler while playing pauses – and the button says Play again.
const playButton = await json('__gm.animation.playAt()');
await click(playButton.x, playButton.y);
const whilePlaying = (await json('__gm.animation.playAt()')).text;
const ruler = await json('__gm.animation.rulerAt(2)');
await click(ruler.x, ruler.y);
const scrubbed = {
text: (await json('__gm.animation.playAt()')).text,
...(await json('__gm.animation.view()')),
};
check(
'scrubbing on the ruler while playing pauses, and the button turns back to Play',
whilePlaying === 'Pause' && scrubbed.text === 'Play' && scrubbed.playing === false,
JSON.stringify({ whilePlaying, scrubbed }),
);
// 25i-ii. A double click into a row without keys keys what its header showed (#412): the Arm
// stands at x 40, not at the property's default 0.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === 'an-box').objects[0].position.x = 40;
p.assets.find((a) => a.id === 'an-walk').tracks = [{ id: 'an-r4', target: 'an-arm', property: 'x', keys: [] }];
`);
await sleep(150);
const shownX = (await json('__gm.animation.lines()')).find((l) => l.kind !== 'group')?.value;
const emptyCell = await json("__gm.animation.cellAt('an-r4', 3)");
await doubleClick(emptyCell.x, emptyCell.y);
const keyedX = (await walk()).tracks[0].keys;
check(
'a double click into an empty row keys the value its header showed – 40, not the default 0',
shownX === 40 && JSON.stringify(keyedX) === JSON.stringify([{ frame: 3, value: 40 }]),
JSON.stringify({ shownX, keyedX }),
);
// 25j. More rows than room (#413): 16 objects with a row each. The wheel over the header column
// scrolls the rows, the last one comes into view and its field keys a typed value; the
// scrollbar's thumb dragged up takes them back to the top.
await mutate(`
const p = __gm.state.project;
const box = p.assets.find((a) => a.id === 'an-box');
const arm = box.objects[0];
for (let i = 2; i <= 16; i++)
box.objects.push({ ...structuredClone(arm), id: 'an-arm' + i, name: 'Arm ' + i });
p.assets.find((a) => a.id === 'an-walk').tracks = box.objects.map((o, i) => (
{ id: i === 15 ? 'an-last' : 'an-s' + i, target: o.id, property: 'x', keys: [] }
));
`);
await evaluate('__gm.animation.scrub(2)');
await sleep(150);
const before = { view: await json('__gm.animation.view()'), thumb: await json('__gm.animation.thumb()') };
const lastHidden = await json("__gm.animation.valueAt('an-last')");
const header = await json("__gm.animation.headerAt('an-s0')");
await wheel(header.x, header.y, 5000);
const scrolled = await json('__gm.animation.view()');
const lastField = await json("__gm.animation.valueAt('an-last')");
if (lastField) {
await click(lastField.x, lastField.y);
await typeText('77');
await press('Enter');
await sleep(150);
}
const lastKeys = (await walk()).tracks.find((r) => r.id === 'an-last').keys;
check(
'the wheel over the headers scrolls to the last row, and its field keys a typed value',
before.view.scrollY === 0 &&
before.thumb !== null &&
lastHidden === null &&
scrolled.scrollY === before.thumb.max &&
scrolled.cellWidth === before.view.cellWidth &&
lastField !== null &&
JSON.stringify(lastKeys) === JSON.stringify([{ frame: 2, value: 77 }]),
JSON.stringify({ before, lastHidden, scrolled, lastField, lastKeys }),
);
await shot('21-scrolled-rows');
const thumb = await json('__gm.animation.thumb()');
await drag(thumb.x, thumb.y, thumb.x, 5);
const toTop = await json('__gm.animation.view()');
check(
'the scrollbar thumb dragged up takes the rows back to the top',
toTop.scrollY === 0 && (await json("__gm.animation.valueAt('an-last')")) === null,
JSON.stringify({ thumb, toTop }),
);
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);
}
}