Files
feuerwerk/tests/editor/timeline-view.test.ts
T
adminandClaude Opus 5.5 480db9c4e4
game-maker / gate (push) Successful in 4m28s
game-maker / suite (push) Successful in 18m13s
Scroll the rows of the animation timeline up and down (#413)
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>
2026-10-13 07:22:11 +02:00

214 lines
8.0 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,
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);
});
});