Add unit tests for AccuracyTracker (15 tests) and MidiClock (14 tests)
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user