Files
STSheets/tests/align/page/edit.test.ts
T
Lucas WintherandClaude Opus 5.5 894fd9b9ef feat(align): alignment web page
Token-protected Bun.serve server (bun run align [--host] [--port]
[--url-host] [--root]) with the layout-A page: recording and warped-MIDI
lanes on one zoomable timeline, draggable anchors (recording end / MIDI
end), stretch tempo labels with warnings, fit-flagged windows, Web Audio
playback with mix choice, auto-align, save, reset, undo. API per the task
brief; transcode/peaks cache under reference/<id>/. Adds bun run align
auto <id>, writeSmf (tests), encodeWav, recordingOnsets/fitFromOnsets,
fitSummary and stretch tempo helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:38:36 +01:00

69 lines
3.1 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import { dragAudio, dragMidi, hitAnchor, anchorAtCursor, MIN_GAP_SEC } from "../../../src/align/page/edit.ts";
import { makeWarp, type Anchor } from "../../../src/align/warp.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("dragAudio (recording end)", () => {
test("moves audioSec, keeps midiSec, returns the anchor's new index", () => {
const r = dragAudio(base, 1, 23);
expect(r.anchors[r.index]).toEqual(m(20, 23));
expect(r.anchors).toHaveLength(3);
});
test("cannot cross manual neighbours", () => {
expect(dragAudio(base, 1, 5).anchors[1]!.audioSec).toBeCloseTo(11 + MIN_GAP_SEC, 9);
expect(dragAudio(base, 1, 99).anchors[1]!.audioSec).toBeCloseTo(32 - MIN_GAP_SEC, 9);
});
test("dragging an auto anchor makes it manual and drops the autos in its stretches", () => {
const list = [m(0, 0), a(5, 5.2), a(10, 10.3), a(15, 15.1), m(20, 20)];
const r = dragAudio(list, 2, 10.5);
expect(r.anchors).toEqual([m(0, 0), m(10, 10.5), m(20, 20)]);
expect(r.index).toBe(1);
});
});
describe("dragMidi (MIDI end): the grabbed MIDI moment follows the mouse, audioSec stays", () => {
for (const [grab, to] of [[22, 23.5], [22, 20.5], [21, 25], [24, 21]] as const) {
test(`grab at ${grab}s, drag to ${to}s`, () => {
const m0 = makeWarp(base).toMidi(grab);
const r = dragMidi(base, 1, m0, to);
expect(r.anchors[r.index]!.audioSec).toBe(22);
expect(makeWarp(r.anchors).toAudio(m0)).toBeCloseTo(to, 6);
});
}
test("no drag = no change", () => {
const r = dragMidi(base, 1, 20, 22);
expect(r.anchors[1]!.midiSec).toBeCloseTo(20, 6);
});
test("clamped between manual neighbours' MIDI times", () => {
const r = dragMidi(base, 1, 20, 31.9);
expect(r.anchors[1]!.midiSec).toBeGreaterThanOrEqual(10 + MIN_GAP_SEC - 1e-9);
const s = dragMidi(base, 1, 20, 11.1);
expect(s.anchors[1]!.midiSec).toBeLessThanOrEqual(30 - MIN_GAP_SEC + 1e-9);
});
test("works for the first and only anchor (edge extrapolation)", () => {
const r = dragMidi([m(10, 11)], 0, 10, 12);
expect(makeWarp(r.anchors).toAudio(10)).toBeCloseTo(12, 6);
expect(r.anchors[0]!.audioSec).toBe(11);
const f = dragMidi(base, 0, 10, 12);
expect(makeWarp(f.anchors).toAudio(10)).toBeCloseTo(12, 6);
});
});
test("hitAnchor picks the nearest anchor line within tolerance", () => {
const v = { start: 0, pxPerSec: 10, width: 500 };
expect(hitAnchor(base, v, 221, 6)).toBe(1);
expect(hitAnchor(base, v, 113, 6)).toBe(0);
expect(hitAnchor(base, v, 160, 6)).toBe(-1);
});
test("anchorAtCursor adds a manual anchor where the current warp maps the cursor", () => {
const r = anchorAtCursor(base, 27);
const k = r.anchors.findIndex((x) => x.audioSec === 27);
expect(r.index).toBe(k);
expect(r.anchors[k]).toEqual(m(makeWarp(base).toMidi(27), 27));
expect(() => anchorAtCursor(base, 22)).toThrow(); // already an anchor there
});