152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
/**
|
|
* MidiClock tests
|
|
*
|
|
* MidiClock's core job is to track "doc-time" (song position in seconds)
|
|
* using this formula:
|
|
*
|
|
* currentSeconds = _startOffset + Tone.getTransport().seconds * tempoMultiplier
|
|
*
|
|
* We mock Tone.js so we can control Transport.seconds without a real audio context.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
// ── Tone.js mock ──────────────────────────────────────────────────────────────
|
|
// vi.hoisted() runs before module-level code so the value is available inside
|
|
// the vi.mock factory despite vi.mock being hoisted to the top of the file.
|
|
|
|
const mockTransport = vi.hoisted(() => ({
|
|
seconds: 0,
|
|
bpm: { value: 120 },
|
|
state: "stopped" as "started" | "stopped" | "paused",
|
|
cancel: vi.fn(),
|
|
start: vi.fn(),
|
|
pause: vi.fn(),
|
|
stop: vi.fn(),
|
|
scheduleRepeat: vi.fn(),
|
|
scheduleOnce: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("tone", () => ({
|
|
getTransport: () => mockTransport,
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
import { MidiClock } from "../engine/MidiClock";
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MidiClock", () => {
|
|
let clock: MidiClock;
|
|
|
|
beforeEach(() => {
|
|
mockTransport.seconds = 0;
|
|
mockTransport.cancel.mockClear();
|
|
mockTransport.scheduleRepeat.mockClear();
|
|
clock = new MidiClock();
|
|
});
|
|
|
|
// ── currentSeconds formula ────────────────────────────────────────────────
|
|
|
|
it("returns 0 on creation (no offset, no transport time)", () => {
|
|
expect(clock.currentSeconds).toBe(0);
|
|
});
|
|
|
|
it("currentSeconds = startOffset + transport.seconds * multiplier", () => {
|
|
clock.seek(2); // _startOffset = 2, transport.seconds = 0
|
|
mockTransport.seconds = 3;
|
|
// 2 + 3 * 1.0 = 5
|
|
expect(clock.currentSeconds).toBe(5);
|
|
});
|
|
|
|
it("applies tempoMultiplier to transport.seconds", () => {
|
|
clock.seek(0);
|
|
mockTransport.seconds = 4;
|
|
clock.tempoMultiplier = 0.5;
|
|
// After setting multiplier, _startOffset is recomputed to preserve currentSeconds.
|
|
// currentSeconds before set = 0 + 4*1 = 4; after set: offset = 4 - 4*0.5 = 2
|
|
// currentSeconds after = 2 + 4*0.5 = 4
|
|
expect(clock.currentSeconds).toBeCloseTo(4);
|
|
});
|
|
|
|
// ── seek ─────────────────────────────────────────────────────────────────
|
|
|
|
it("seek sets startOffset and resets transport.seconds to 0", () => {
|
|
mockTransport.seconds = 10;
|
|
clock.seek(5);
|
|
expect(mockTransport.seconds).toBe(0);
|
|
expect(clock.currentSeconds).toBe(5);
|
|
});
|
|
|
|
it("seek to 0 resets position to start", () => {
|
|
clock.seek(30);
|
|
clock.seek(0);
|
|
expect(clock.currentSeconds).toBe(0);
|
|
});
|
|
|
|
// ── tempoMultiplier ───────────────────────────────────────────────────────
|
|
|
|
describe("tempoMultiplier", () => {
|
|
it("defaults to 1.0", () => {
|
|
expect(clock.tempoMultiplier).toBe(1.0);
|
|
});
|
|
|
|
it("clamps values below 0.25 to 0.25", () => {
|
|
clock.tempoMultiplier = 0.1;
|
|
expect(clock.tempoMultiplier).toBe(0.25);
|
|
});
|
|
|
|
it("clamps values above 2.0 to 2.0", () => {
|
|
clock.tempoMultiplier = 5.0;
|
|
expect(clock.tempoMultiplier).toBe(2.0);
|
|
});
|
|
|
|
it("accepts boundary value 0.25", () => {
|
|
clock.tempoMultiplier = 0.25;
|
|
expect(clock.tempoMultiplier).toBe(0.25);
|
|
});
|
|
|
|
it("accepts boundary value 2.0", () => {
|
|
clock.tempoMultiplier = 2.0;
|
|
expect(clock.tempoMultiplier).toBe(2.0);
|
|
});
|
|
|
|
it("preserves currentSeconds when multiplier changes (no time jump)", () => {
|
|
// Set a known position: offset=3, transport=2 → currentSeconds=5
|
|
clock.seek(3);
|
|
mockTransport.seconds = 2;
|
|
expect(clock.currentSeconds).toBe(5);
|
|
|
|
// Change multiplier — currentSeconds must remain 5
|
|
clock.tempoMultiplier = 0.5;
|
|
expect(clock.currentSeconds).toBeCloseTo(5);
|
|
});
|
|
|
|
it("preserves currentSeconds when multiplier increases", () => {
|
|
clock.seek(1);
|
|
mockTransport.seconds = 4;
|
|
// currentSeconds = 1 + 4*1 = 5
|
|
clock.tempoMultiplier = 2.0;
|
|
// _startOffset = 5 - 4*2 = -3; currentSeconds = -3 + 4*2 = 5
|
|
expect(clock.currentSeconds).toBeCloseTo(5);
|
|
});
|
|
});
|
|
|
|
// ── reschedule ────────────────────────────────────────────────────────────
|
|
|
|
it("reschedule resets transport.seconds and re-registers the tick callback", () => {
|
|
clock.reschedule(7);
|
|
expect(mockTransport.seconds).toBe(0);
|
|
expect(mockTransport.scheduleRepeat).toHaveBeenCalledOnce();
|
|
expect(clock.currentSeconds).toBe(7);
|
|
});
|
|
|
|
// ── state ─────────────────────────────────────────────────────────────────
|
|
|
|
it("state reflects the mock transport state", () => {
|
|
mockTransport.state = "paused";
|
|
expect(clock.state).toBe("paused");
|
|
mockTransport.state = "started";
|
|
expect(clock.state).toBe("started");
|
|
});
|
|
});
|