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); } }