Files
feuerwerk/tests/editor/timeline-view.test.ts
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

223 lines
8.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { AnimationTrack } from '../../src/core/model/Asset';
import {
clampMove,
clampPan,
clampScroll,
DEFAULT_VIEW,
frameAt,
frameCentreX,
frameToX,
keysAt,
keysWithin,
MAX_CELL_WIDTH,
maxScrollY,
MIN_CELL_WIDTH,
revealRow,
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
scrollByThumb,
scrollThumb,
type TimelineView,
xToFrame,
zoomCells,
} from '../../src/editor/animation/timelineView';
const TRACKS: AnimationTrack[] = [
{
id: 'a',
target: '',
property: 'x',
keys: [
{ frame: 0, value: 0 },
{ frame: 4, value: 40 },
],
},
{ id: 'b', target: '', property: 'y', keys: [{ frame: 2, value: 0 }] },
];
describe('timeline geometry', () => {
it('maps frames to cells and back', () => {
const view: TimelineView = { panX: 10, cellWidth: 20, scrollY: 0 };
expect(frameToX(view, 0)).toBe(10);
expect(frameToX(view, 3)).toBe(70);
expect(frameCentreX(view, 3)).toBe(80);
expect(xToFrame(view, 80)).toBe(3.5);
expect(frameAt(view, 89)).toBe(3);
expect(frameAt(view, 90)).toBe(4);
});
it('never answers a frame below 0', () => {
expect(frameAt({ panX: 0, cellWidth: 16, scrollY: 0 }, -40)).toBe(0);
});
it('finds rows below the ruler and nothing beyond the last', () => {
expect(rowAt(RULER_HEIGHT - 1, 2)).toBe(-1);
expect(rowAt(RULER_HEIGHT, 2)).toBe(0);
expect(rowAt(RULER_HEIGHT + ROW_HEIGHT, 2)).toBe(1);
expect(rowAt(RULER_HEIGHT + 2 * ROW_HEIGHT, 2)).toBe(-1);
});
it('zooms about the pointer, so the frame under it stays put', () => {
const view: TimelineView = { panX: -100, cellWidth: 16, scrollY: 0 };
const x = 200;
const before = xToFrame(view, x);
const zoomed = zoomCells(view, 1.5, x);
expect(zoomed.cellWidth).toBe(24);
expect(xToFrame(zoomed, x)).toBeCloseTo(before);
});
it('keeps the cell width within its bounds', () => {
expect(zoomCells(DEFAULT_VIEW, 100, 0).cellWidth).toBe(MAX_CELL_WIDTH);
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
});
it('zooms out far enough that an hour of video at 30 fps fits a screen (#407)', () => {
const out = zoomCells(DEFAULT_VIEW, 1e-6, 0);
expect(frameToX(out, 3600 * 30)).toBeLessThanOrEqual(2200);
});
it('does not let frame 0 drift right of the left edge', () => {
expect(clampPan({ panX: 30, cellWidth: 16, scrollY: 0 }).panX).toBe(0);
expect(clampPan({ panX: -30, cellWidth: 16, scrollY: 0 }).panX).toBe(-30);
});
it('labels the ruler with a step that keeps the numbers apart', () => {
expect(rulerStep({ panX: 0, cellWidth: 48, scrollY: 0 })).toBe(1);
expect(rulerStep({ panX: 0, cellWidth: 16, scrollY: 0 })).toBe(5);
expect(rulerStep({ panX: 0, cellWidth: 4, scrollY: 0 })).toBe(10);
expect(rulerStep({ panX: 0, cellWidth: 0.1, scrollY: 0 })).toBe(500);
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH, scrollY: 0 })).toBe(2000);
});
});
describe('picking keys', () => {
const view: TimelineView = { panX: 0, cellWidth: 16, scrollY: 0 };
it('hits the key drawn at the centre of its cell', () => {
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual([
{ trackId: 'a', frame: 4 },
]);
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual([
{ trackId: 'b', frame: 2 },
]);
});
it('misses an empty cell and the ruler', () => {
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toEqual([]);
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toEqual([]);
});
it('a band catches every key whose diamond centre it spans, across rows', () => {
const a = { x: frameToX(view, 1), y: RULER_HEIGHT };
const b = { x: frameToX(view, 5), y: RULER_HEIGHT + 2 * ROW_HEIGHT };
expect(keysWithin(view, TRACKS, a, b)).toEqual([
{ trackId: 'a', frame: 4 },
{ trackId: 'b', frame: 2 },
]);
// Spanned the other way round it is the same band.
expect(keysWithin(view, TRACKS, b, a)).toHaveLength(2);
});
it('a band over one row leaves the other alone', () => {
const a = { x: frameToX(view, 0), y: RULER_HEIGHT };
const b = { x: frameToX(view, 10), y: RULER_HEIGHT + ROW_HEIGHT - 1 };
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
});
it("skips an object's head between the rows (#408)", () => {
const lanes = [null, TRACKS[0]!, null, TRACKS[1]!];
expect(keysAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual([]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual([
{ trackId: 'a', frame: 4 },
]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })[0]?.trackId).toBe('b');
const all = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(4) });
expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']);
});
it('finds the key of a row scrolled up, and nothing under the ruler (#413)', () => {
const scrolled: TimelineView = { ...view, scrollY: ROW_HEIGHT };
expect(keysAt(scrolled, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })[0]?.trackId).toBe('b');
expect(rowAt(RULER_HEIGHT - 1, 2, ROW_HEIGHT)).toBe(-1);
const band = keysWithin(
scrolled,
TRACKS,
{ x: 0, y: RULER_HEIGHT },
{ x: frameToX(view, 10), y: rowTop(1) },
);
expect(band).toEqual([{ trackId: 'b', frame: 2 }]);
});
it("a head's diamond stands for every key of its group on that frame, and a band counts each key once (#414)", () => {
const both = [{ ...TRACKS[0]!, keys: [...TRACKS[0]!.keys, { frame: 2, value: 20 }] }, TRACKS[1]!];
const lanes = [{ target: '', tracks: both }, ...both];
expect(keysAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toEqual([
{ trackId: 'a', frame: 2 },
{ trackId: 'b', frame: 2 },
]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 3), y: rowCentreY(0) })).toEqual([]);
const band = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(3) });
expect(band).toHaveLength(4);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },
{ trackId: 'b', frame: 5 },
];
expect(clampMove(keys, -5)).toBe(-2);
expect(clampMove(keys, 3)).toBe(3);
expect(clampMove([], -3)).toBe(0);
});
});
describe('scrolling the rows (#413)', () => {
// Room for 4 rows below the ruler, and 10 of them: 6 rows' worth to scroll.
const height = RULER_HEIGHT + 4 * ROW_HEIGHT;
const at = (scrollY: number): TimelineView => ({ ...DEFAULT_VIEW, scrollY });
it('scrolls no further than the last row coming up to the bottom edge, and not above the first', () => {
expect(maxScrollY(10, height)).toBe(6 * ROW_HEIGHT);
expect(clampScroll(at(10_000), 10, height).scrollY).toBe(6 * ROW_HEIGHT);
expect(clampScroll(at(-40), 10, height).scrollY).toBe(0);
expect(maxScrollY(3, height)).toBe(0);
expect(clampScroll(at(50), 3, height).scrollY).toBe(0);
});
it('brings a picked row into view, just far enough, and leaves one in view alone', () => {
expect(revealRow(at(0), 9, 10, height).scrollY).toBe(6 * ROW_HEIGHT);
expect(revealRow(at(0), 5, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
expect(revealRow(at(6 * ROW_HEIGHT), 1, 10, height).scrollY).toBe(ROW_HEIGHT);
expect(revealRow(at(2 * ROW_HEIGHT), 3, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
// Half hidden under the ruler is out of view.
expect(revealRow(at(ROW_HEIGHT / 2), 0, 10, height).scrollY).toBe(0);
});
it('shows a thumb only while the rows do not fit, from top to bottom of its track', () => {
expect(scrollThumb(at(0), 4, height)).toBeNull();
const top = scrollThumb(at(0), 10, height)!;
const bottom = scrollThumb(at(6 * ROW_HEIGHT), 10, height)!;
expect(top.top).toBe(RULER_HEIGHT);
expect(top.height).toBeCloseTo((4 * ROW_HEIGHT * 4) / 10);
expect(bottom.top + bottom.height).toBeCloseTo(height);
});
it('a thumb dragged its whole travel scrolls the whole way, and no further', () => {
const thumb = scrollThumb(at(0), 10, height)!;
const travel = height - RULER_HEIGHT - thumb.height;
expect(scrollByThumb(0, travel, 10, height)).toBeCloseTo(6 * ROW_HEIGHT);
expect(scrollByThumb(0, travel / 2, 10, height)).toBeCloseTo(3 * ROW_HEIGHT);
expect(scrollByThumb(0, 10 * travel, 10, height)).toBe(6 * ROW_HEIGHT);
expect(scrollByThumb(ROW_HEIGHT, -10 * travel, 10, height)).toBe(0);
});
it('zooming keeps the rows where they were scrolled to', () => {
expect(zoomCells(at(40), 2, 0).scrollY).toBe(40);
});
});