import { test, expect, describe } from "bun:test"; import { NO_MARKS, placeMark, midiMarkAt, markLaneSec, linkMarks, linkConflict, parseVolumes } from "../../../src/align/page/edit.ts"; import { makeWarp, type Anchor } from "../../../src/align/warp.ts"; import { parseTime, formatTime, buildPyramid, buildPyramidSliced } from "../../../src/align/page/timeline.ts"; const m = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "manual" }); const a = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "auto" }); const base = [m(10, 11), m(20, 22), m(30, 32)]; describe("pair marks", () => { test("placing marks only changes the marks (anchors untouched)", () => { const before = JSON.stringify(base); let s = placeMark(NO_MARKS, "rec", 25); s = placeMark(s, "midi", midiMarkAt(base, 26)); expect(s).toEqual({ rec: 25, midi: makeWarp(base).toMidi(26), active: "midi" }); expect(JSON.stringify(base)).toBe(before); expect(placeMark(s, "rec", 27)).toEqual({ ...s, rec: 27, active: "rec" }); }); test("markLaneSec: rec mark is recording time, MIDI mark is drawn where the warp puts it", () => { const s = placeMark(placeMark(NO_MARKS, "rec", 25), "midi", midiMarkAt(base, 26)); expect(markLaneSec(base, s, "rec")).toBe(25); expect(markLaneSec(base, s, "midi")).toBeCloseTo(26, 9); expect(markLaneSec(base, NO_MARKS, "midi")).toBeNull(); }); }); describe("linkMarks", () => { test("creates a manual anchor {audioSec: rec mark, midiSec: warp.toMidi(MIDI-lane mark)}", () => { const midi = midiMarkAt(base, 26); const r = linkMarks(base, { rec: 25, midi, active: "midi" }); expect(r.anchors[r.index]).toEqual(m(makeWarp(base).toMidi(26), 25)); expect(r.anchors).toHaveLength(4); expect(makeWarp(r.anchors).toAudio(midi)).toBeCloseTo(25, 9); // that MIDI moment now plays at the recording mark }); test("needs both marks", () => { expect(() => linkMarks(base, placeMark(NO_MARKS, "rec", 25))).toThrow(/both/); }); test("refuses to cross a manual neighbour, with a clear message", () => { // MIDI mark between anchors 20 and 30, recording mark before the anchor at 22 s const s = { rec: 21, midi: 25, active: "rec" as const }; expect(linkConflict(base, 25, 21)).toMatch(/same order/); expect(() => linkMarks(base, s)).toThrow(/Can't link/); expect(linkConflict(base, 15, 33)).toMatch(/MIDI mark \(0:16\.50\) is before your anchor at 0:22\.00/); expect(linkConflict(base, 20, 21.5)).toMatch(/already uses that MIDI moment/); expect(linkConflict(base, 25, 27)).toBeNull(); }); test("automatic anchors never block a link (they are dropped)", () => { const list = [m(0, 0), a(5, 5.2), a(10, 10.3), m(40, 40)]; expect(linkConflict(list, 10, 4)).toBeNull(); const r = linkMarks(list, { rec: 4, midi: 10, active: "rec" }); expect(r.anchors).toEqual([m(0, 0), m(10, 4), m(40, 40)]); }); }); test("parseVolumes clamps to 0–150 and defaults to 100", () => { expect(parseVolumes(null)).toEqual({ rec: 100, midi: 100 }); expect(parseVolumes("garbage")).toEqual({ rec: 100, midi: 100 }); expect(parseVolumes(JSON.stringify({ rec: 0, midi: 999 }))).toEqual({ rec: 0, midi: 150 }); expect(parseVolumes(JSON.stringify({ rec: 42.4, midi: "x" }))).toEqual({ rec: 42, midi: 100 }); }); describe("parseTime", () => { test("m:ss.s, h:mm:ss, plain seconds, comma decimals", () => { expect(parseTime("0:23.2")).toBeCloseTo(23.2, 9); expect(parseTime(" 1:05 ")).toBe(65); expect(parseTime("1:02:03.5")).toBeCloseTo(3723.5, 9); expect(parseTime("24")).toBe(24); expect(parseTime("24,5")).toBe(24.5); expect(parseTime(".5")).toBe(0.5); expect(parseTime(formatTime(83.25, 2))).toBeCloseTo(83.25, 9); }); test("rejects junk, negatives and seconds >= 60 in m:ss", () => { for (const s of ["", "abc", "-3", "1:60", "1::2", "1:2:3:4", "0:23.2.1", "12s"]) expect(parseTime(s)).toBeNull(); }); }); test("buildPyramidSliced matches buildPyramid and takes the min/max across channels", async () => { const x = Float32Array.from({ length: 10_000 }, (_, i) => Math.sin(i / 13) * (i % 7) / 7); let pauses = 0; const a = await buildPyramidSliced([x], 64, async () => { pauses++; }, 1000); const b = buildPyramid(x, 64); expect(pauses).toBeGreaterThan(5); expect(a.length).toBe(b.length); a.forEach((l, k) => { expect(l.bucket).toBe(b[k]!.bucket); expect([...l.min]).toEqual([...b[k]!.min]); expect([...l.max]).toEqual([...b[k]!.max]); }); const y = x.map((v) => -v * 2); const c = await buildPyramidSliced([x, y], 64, async () => {}); expect(c[0]!.max[3]).toBe(Math.max(...x.subarray(192, 256), ...y.subarray(192, 256))); });