import { describe, expect, it } from 'vitest'; import type { AnimationTrack } from '../../src/core/model/Asset'; import { clampMove, clampPan, DEFAULT_VIEW, frameAt, frameCentreX, frameToX, keyAt, keysWithin, MAX_CELL_WIDTH, MIN_CELL_WIDTH, ROW_HEIGHT, rowAt, rowCentreY, rowTop, RULER_HEIGHT, rulerStep, 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 }; 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 }, -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 }; 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 }).panX).toBe(0); expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30); }); it('labels the ruler with a step that keeps the numbers apart', () => { expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1); expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5); expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10); expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500); expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000); }); }); describe('picking keys', () => { const view: TimelineView = { panX: 0, cellWidth: 16 }; 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('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); }); });