import { describe, expect, it } from 'vitest'; import type { AnimationTrack } from '../../src/core/model/Asset'; import { clampMove, clampPan, clampScroll, DEFAULT_VIEW, frameAt, frameCentreX, frameToX, keyAt, 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(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({ trackId: 'a', frame: 4, }); expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({ trackId: 'b', frame: 2, }); }); it('misses an empty cell and the ruler', () => { expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull(); expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull(); }); 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(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toBeNull(); expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual({ trackId: 'a', frame: 4, }); expect(keyAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })?.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(keyAt(scrolled, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(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 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); }); });