diff --git a/frontend/src/__tests__/AccuracyTracker.test.ts b/frontend/src/__tests__/AccuracyTracker.test.ts new file mode 100644 index 0000000..d9823c0 --- /dev/null +++ b/frontend/src/__tests__/AccuracyTracker.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import bus from "../engine/EventBus"; +import { accuracyTracker } from "../engine/AccuracyTracker"; +import type { NoteEvent } from "../../../shared/types/MusicData"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeNote( + id: string, + midi: number, + hand: "left" | "right" | "unknown" = "right" +): NoteEvent { + return { + id, + midi, + pitch: "C4", + startTick: 0, + durationTick: 480, + startSeconds: 0, + endSeconds: 1, + hand, + finger: null, + velocity: 80, + channel: 0, + }; +} + +type AccuracyUpdate = { correct: number; wrong: number; missed: number; score: number }; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("AccuracyTracker", () => { + let updates: AccuracyUpdate[] = []; + + const listener = (data: AccuracyUpdate) => { + updates.push(data); + }; + + beforeEach(() => { + updates = []; + bus.on("accuracy:update", listener); + accuracyTracker.enable(true); + accuracyTracker.reset(); + }); + + afterEach(() => { + bus.off("accuracy:update", listener); + accuracyTracker.enable(false); + }); + + // ── Initial state ───────────────────────────────────────────────────────── + + it("starts with score 0 and no counts", () => { + expect(accuracyTracker.score).toBe(0); + }); + + // ── Correct hit ─────────────────────────────────────────────────────────── + + it("records a correct hit when input arrives within the window", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); + + const last = updates[updates.length - 1]; + expect(last.correct).toBe(1); + expect(last.wrong).toBe(0); + expect(last.missed).toBe(0); + expect(last.score).toBe(100); + }); + + it("score is 100 after a single correct hit", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); + + expect(accuracyTracker.score).toBe(100); + }); + + // ── Miss ───────────────────────────────────────────────────────────────── + + it("records a miss when note:off fires without any input", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("note:off", note); + + const last = updates[updates.length - 1]; + expect(last.missed).toBe(1); + expect(last.correct).toBe(0); + expect(last.score).toBe(0); + }); + + it("ignores note:off for a note that was already hit", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // correct hit + const countBefore = updates.length; + bus.emit("note:off", note); // already hit — should not emit again + + // No new accuracy:update should have been emitted for the note:off + expect(updates.length).toBe(countBefore); + const last = updates[updates.length - 1]; + expect(last.missed).toBe(0); + }); + + // ── Wrong ───────────────────────────────────────────────────────────────── + + it("records wrong when input MIDI does not match any pending note", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 61, velocity: 80 }); // wrong key + + const last = updates[updates.length - 1]; + expect(last.wrong).toBe(1); + expect(last.correct).toBe(0); + }); + + it("records wrong when no notes are pending at all", () => { + bus.emit("input:note:on", { midi: 60, velocity: 80 }); + + const last = updates[updates.length - 1]; + expect(last.wrong).toBe(1); + }); + + it("second input for the same already-hit note counts as wrong", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // correct + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // note already marked hit → wrong + + const last = updates[updates.length - 1]; + expect(last.correct).toBe(1); + expect(last.wrong).toBe(1); + }); + + // ── Score formula ───────────────────────────────────────────────────────── + + it("score = correct / total * 100, rounded", () => { + // 2 correct + 1 wrong + 1 missed = 4 total → 50% + const n1 = makeNote("n1", 60); + const n2 = makeNote("n2", 62); + const n3 = makeNote("n3", 64); + + bus.emit("note:on", n1); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // correct + + bus.emit("note:on", n2); + bus.emit("input:note:on", { midi: 62, velocity: 80 }); // correct + + bus.emit("note:on", n3); + bus.emit("note:off", n3); // missed + + bus.emit("input:note:on", { midi: 99, velocity: 80 }); // wrong (no pending note) + + const last = updates[updates.length - 1]; + expect(last.correct).toBe(2); + expect(last.missed).toBe(1); + expect(last.wrong).toBe(1); + expect(last.score).toBe(50); + }); + + it("score rounds to nearest integer", () => { + // 2 correct + 1 wrong = 3 total → 66.67% → rounds to 67 + const n1 = makeNote("n1", 60); + const n2 = makeNote("n2", 62); + + bus.emit("note:on", n1); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // correct + + bus.emit("note:on", n2); + bus.emit("input:note:on", { midi: 62, velocity: 80 }); // correct + + bus.emit("input:note:on", { midi: 99, velocity: 80 }); // wrong + + expect(accuracyTracker.score).toBe(67); + }); + + // ── Multiple simultaneous pending notes ─────────────────────────────────── + + it("hits the correct note among multiple simultaneously pending notes", () => { + const c = makeNote("n1", 60); + const e = makeNote("n2", 64); + const g = makeNote("n3", 67); + + bus.emit("note:on", c); + bus.emit("note:on", e); + bus.emit("note:on", g); + + bus.emit("input:note:on", { midi: 64, velocity: 80 }); // hits E + bus.emit("input:note:on", { midi: 60, velocity: 80 }); // hits C + bus.emit("note:off", g); // G not played → missed + + const last = updates[updates.length - 1]; + expect(last.correct).toBe(2); + expect(last.missed).toBe(1); + expect(last.wrong).toBe(0); + }); + + // ── Reset ───────────────────────────────────────────────────────────────── + + it("reset clears all counts and emits zeros", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("note:off", note); // missed + + accuracyTracker.reset(); + + const last = updates[updates.length - 1]; + expect(last.correct).toBe(0); + expect(last.wrong).toBe(0); + expect(last.missed).toBe(0); + expect(last.score).toBe(0); + expect(accuracyTracker.score).toBe(0); + }); + + // ── Disabled ───────────────────────────────────────────────────────────── + + it("emits nothing and records nothing when disabled", () => { + accuracyTracker.enable(false); + const countBefore = updates.length; + + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("input:note:on", { midi: 60, velocity: 80 }); + bus.emit("note:off", note); + + expect(updates.length).toBe(countBefore); + expect(accuracyTracker.score).toBe(0); + }); + + // ── Transport events ────────────────────────────────────────────────────── + + it("resets when transport stops", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); + bus.emit("note:off", note); // missed → score 0% + + bus.emit("transport:stateChange", { status: "stopped", seconds: 0 }); + + expect(accuracyTracker.score).toBe(0); + const last = updates[updates.length - 1]; + expect(last.missed).toBe(0); + }); + + it("seek clears pending notes so subsequent note:off does not count as missed", () => { + const note = makeNote("n1", 60); + bus.emit("note:on", note); // enters pending + bus.emit("transport:seek", { seconds: 5 }); // clears pending + + const countBefore = updates.length; + bus.emit("note:off", note); // pending was cleared — should be a no-op + + // No new accuracy:update should have fired + expect(updates.length).toBe(countBefore); + expect(accuracyTracker.score).toBe(0); + }); +}); diff --git a/frontend/src/__tests__/MidiClock.test.ts b/frontend/src/__tests__/MidiClock.test.ts new file mode 100644 index 0000000..f48a15f --- /dev/null +++ b/frontend/src/__tests__/MidiClock.test.ts @@ -0,0 +1,151 @@ +/** + * 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"); + }); +});