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>
564 lines
24 KiB
JavaScript
564 lines
24 KiB
JavaScript
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);
|
||
}
|
||
}
|