Files
adminandClaude Opus 5.5 37779a2070
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 20m7s
Fold object groups in the timeline, with summary keys (#414)
Long animations with many objects fill the timeline with rows. A
group per object can now fold away, and its header row still shows
where the group has keys, so they stay reachable while folded.

- A click on an object header or its triangle folds the group. The
  groups of the objects below it fold along.
- Which groups fold along follows the real parent in the hierarchy,
  not the indent. The root shows at the level of its children, so
  folding it hides only its own rows.
- Every header shows a summary diamond on each frame where a row of
  the group or of a group below has a key. Click selects all those
  keys, drag moves them, Ctrl+drag duplicates, a band catches them
  and Delete removes them, each through the existing commands.
- The fold state is a view of the session per animation. It is no
  undo step and is not saved.
- Selecting a row without keys inside a folded group unfolds it, so
  a fresh row from + Row does not stay hidden.
- The fold rules live in the new timelineFold.ts. keyAt became
  keysAt, which returns every key behind a summary diamond.

Found on the way: the triangle moved the object name to x 22 while
its rows stayed at x 20, left of the name (#408 check in e2e 25).
Rows now sit one step behind the name, as in the hierarchy.

Tests:
- Unit animation + timeline-view 114/114, 6 new
- 6 counter-checks, all red in the matching test
- e2e 25 (atx) ALL PASSED, 41 checks, 4 new (25k)
- Gate (atx) CHECK EXIT 0, 324 files, 5008 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:01:30 +02:00

641 lines
27 KiB
JavaScript
Raw Permalink 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 }),
);
// 25k. Folding a group (#414): the Arm with a Hand under it. Folded, the Arm's rows and the Hand's
// group are gone and the head shows a diamond on every keyed frame; dragging the one on 6 to 8
// moves both keys there in one undo step, and Delete on the one on 4 takes the Hand's key.
// Open again, the head keeps its diamonds.
await mutate(`
const p = __gm.state.project;
const box = p.assets.find((a) => a.id === 'an-box');
const arm = box.objects[0];
box.objects = [arm, { ...structuredClone(arm), id: 'an-hand', name: 'Hand', parentId: arm.id }];
p.assets.find((a) => a.id === 'an-walk').tracks = [
{ id: 'an-k1', target: arm.id, property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 6, value: 60 }] },
{ id: 'an-k2', target: arm.id, property: 'y', keys: [{ frame: 2, value: 0 }, { frame: 6, value: 10 }] },
{ id: 'an-k3', target: 'an-hand', property: 'rotation', keys: [{ frame: 4, value: 90 }] },
];
`);
await sleep(150);
const tracksNow = async () =>
JSON.stringify((await walk()).tracks.map((r) => [r.id, r.keys.map((k) => k.frame)]));
const tracksBefore = await tracksNow();
const fold = await json("__gm.animation.foldAt('an-arm')");
await click(fold.x, fold.y);
const folded = await json('__gm.animation.lines()');
check(
'a click on the triangle folds the Arm: its rows and the Hand go, the head shows diamonds on 0, 2, 4 and 6',
folded.length === 1 &&
folded[0].text === 'Arm' &&
folded[0].folded === true &&
JSON.stringify(folded[0].summary) === '[0,2,4,6]',
JSON.stringify(folded),
);
await shot('22-folded-group');
const six = await json("__gm.animation.summaryAt('an-arm', 6)");
const eight = await json("__gm.animation.summaryAt('an-arm', 8)");
await drag(six.x, six.y, eight.x, eight.y);
const movedTo = await tracksNow();
await undo();
const movedBack = await tracksNow();
check(
"dragging the diamond on 6 to 8 moves both of the Arm's keys there, the Hand's stays, one undo takes it back",
movedTo ===
JSON.stringify([
['an-k1', [0, 8]],
['an-k2', [2, 8]],
['an-k3', [4]],
]) && movedBack === tracksBefore,
JSON.stringify({ movedTo, movedBack }),
);
const four = await json("__gm.animation.summaryAt('an-arm', 4)");
await click(four.x, four.y);
const pickedFour = await json('__gm.state.selectedKeys');
await press('Delete');
const foldedDelete = await tracksNow();
await undo();
check(
"the diamond on 4 picks the folded Hand's key, Delete removes it, undo brings it back, the group stays folded",
JSON.stringify(pickedFour) === JSON.stringify([{ trackId: 'an-k3', frame: 4 }]) &&
foldedDelete ===
JSON.stringify([
['an-k1', [0, 6]],
['an-k2', [2, 6]],
['an-k3', []],
]) &&
(await tracksNow()) === tracksBefore &&
(await json('__gm.animation.lines()')).length === 1,
JSON.stringify({ pickedFour, foldedDelete }),
);
await click(fold.x, fold.y);
const opened = await json('__gm.animation.lines()');
check(
'a second click opens it again, and the open head still shows its diamonds',
JSON.stringify(opened.map((l) => l.text)) === JSON.stringify(['Arm', 'X', 'Y', 'Hand', 'Rotation']) &&
opened[0].folded === false &&
JSON.stringify(opened[0].summary) === '[0,2,4,6]' &&
JSON.stringify(opened[3].summary) === '[4]',
JSON.stringify(opened),
);
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);
}
}