import { test, expect, describe } from "bun:test"; import { timeToX, xToTime, fitView, zoomAt, scrollBy, clampView, follow, formatTime, rulerStep, buildPyramid, columnPeaks, MAX_PX_PER_SEC, type View } from "../../../src/align/page/timeline.ts"; const DUR = 200; describe("view mapping", () => { const v: View = { start: 10, pxPerSec: 50, width: 1000 }; test("time <-> x are inverse", () => { expect(timeToX(v, 12)).toBe(100); expect(xToTime(v, 100)).toBe(12); for (const t of [0, 7.3, 31]) expect(xToTime(v, timeToX(v, t))).toBeCloseTo(t, 12); }); test("fitView shows the whole song", () => { const f = fitView(DUR, 1000); expect(xToTime(f, 0)).toBeLessThanOrEqual(0); expect(xToTime(f, 1000)).toBeGreaterThanOrEqual(DUR); expect(xToTime(f, 1000)).toBeLessThan(DUR * 1.05); }); }); describe("zoom and scroll", () => { const v: View = { start: 50, pxPerSec: 20, width: 1000 }; test("zoomAt keeps the time under the mouse fixed", () => { const t = xToTime(v, 300); const z = zoomAt(v, 300, 2, DUR); expect(z.pxPerSec).toBe(40); expect(xToTime(z, 300)).toBeCloseTo(t, 9); const o = zoomAt(v, 700, 0.8, DUR); expect(xToTime(o, 700)).toBeCloseTo(xToTime(v, 700), 9); }); test("zoom is clamped: cannot zoom out past the whole song or in past the maximum", () => { const out = zoomAt(v, 500, 1e-6, DUR); expect(out.pxPerSec).toBeCloseTo(fitView(DUR, 1000).pxPerSec, 9); const inn = zoomAt(v, 500, 1e9, DUR); expect(inn.pxPerSec).toBe(MAX_PX_PER_SEC); }); test("scrollBy moves by pixels (drag right = earlier time) and is clamped to the song", () => { expect(scrollBy(v, -100, DUR).start).toBeCloseTo(55, 9); expect(scrollBy(v, 100, DUR).start).toBeCloseTo(45, 9); expect(xToTime(scrollBy(v, 1e7, DUR), 0)).toBeGreaterThanOrEqual(-DUR * 0.05); expect(xToTime(scrollBy(v, -1e7, DUR), 1000)).toBeLessThanOrEqual(DUR * 1.05 + 1e-9); }); test("clampView leaves a valid view unchanged", () => { expect(clampView(v, DUR)).toEqual(v); }); test("follow pages the view when the playhead leaves it, and not otherwise", () => { expect(follow(v, 60, DUR)).toEqual(v); const f = follow(v, 120, DUR); // visible range is 50..100 expect(f.start).toBeLessThan(120); expect(xToTime(f, 1000)).toBeGreaterThan(120); expect(f.pxPerSec).toBe(v.pxPerSec); }); }); test("formatTime and ruler step", () => { expect(formatTime(58.24)).toBe("0:58.2"); expect(formatTime(130)).toBe("2:10.0"); expect(formatTime(3.04, 2)).toBe("0:03.04"); expect(formatTime(-1.5)).toBe("-0:01.5"); // labelled ticks at least ~80 px apart for (const pps of [0.5, 3, 20, 100, 1000]) expect(rulerStep(pps) * pps).toBeGreaterThanOrEqual(80); expect(rulerStep(20)).toBe(5); }); describe("peaks pyramid", () => { const sr = 1000; const x = new Float32Array(10 * sr); for (let i = 0; i < x.length; i++) x[i] = i >= 2000 && i < 3000 ? (i % 2 ? 0.8 : -0.5) : 0.01; const levels = buildPyramid(x, 16); test("each level halves the previous", () => { expect(levels[0]!.bucket).toBe(16); for (let k = 1; k < levels.length; k++) expect(levels[k]!.bucket).toBe(levels[k - 1]!.bucket * 2); expect(levels.at(-1)!.min.length).toBeLessThanOrEqual(2); }); test("columnPeaks finds the loud second (2..3 s) at any zoom", () => { for (const pxPerSec of [2, 30, 400, 5000]) { const v: View = { start: 0, pxPerSec, width: Math.ceil(10 * pxPerSec) }; const { min, max } = columnPeaks(levels, sr, v); expect(min.length).toBe(v.width); const col = Math.floor(2.5 * pxPerSec); expect(max[col]).toBeCloseTo(0.8, 5); expect(min[col]).toBeCloseTo(-0.5, 5); expect(max[Math.floor(6 * pxPerSec)]).toBeCloseTo(0.01, 5); } }); test("columns outside the audio are silent", () => { const { min, max } = columnPeaks(levels, sr, { start: -5, pxPerSec: 10, width: 20 }); expect(max[5]).toBe(0); expect(min[5]).toBe(0); }); });