import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering"; import { parseMusicXml } from "@/lib/musicxml"; import { parsePitch } from "@/lib/pitch"; import { barStatus, expectedQuarters } from "@/lib/score"; import { odeToJoy, twinkle } from "@/lib/samples"; import { formatTextTab } from "@/lib/text-tab"; const BAROQUE: Record = { C5: "0 1 2 3 4 5 6a 6b 7a 7b", "C#5": "0 1 2 3 4 5 6a 6b 7a", D5: "0 1 2 3 4 5 6a 6b", Eb5: "0 1 2 3 4 5 6a", E5: "0 1 2 3 4 5", F5: "0 1 2 3 4 6a 6b 7a 7b", "F#5": "0 1 2 3 5 6a 6b", G5: "0 1 2 3", Ab5: "0 1 2 4 5 6a", A5: "0 1 2", Bb5: "0 1 3 4", B5: "0 1", C6: "0 2", "C#6": "- 1 2", D6: "- 2", Eb6: "- 2 3 4 5 6a 6b", E6: "x 1 2 3 4 5", F6: "x 1 2 3 4 6a 6b", "F#6": "x 1 2 3 5", G6: "x 1 2 3", Ab6: "x 1 2 4", A6: "x 1 2", Bb6: "x 1 2 5 6a 6b", B6: "x 1 2 4 5", C7: "x 1 4 5", "C#7": "x 1 3 4 6a 6b bell", D7: "x 1 3 4 6a 6b", }; describe("soprano chart", () => { it("covers every semitone from C5 through D7 and no others", () => { const midis = CHART_NOTES.map((note) => parsePitch(note)?.midi); expect(midis).toEqual(Array.from({ length: 27 }, (_, index) => 72 + index)); }); it("matches the Dolmetsch first-choice Baroque rows", () => { for (const [note, code] of Object.entries(BAROQUE)) { const fingering = fingeringFor(note, "baroque"); expect(fingering, note).not.toBeNull(); expect(fingeringCode(fingering!), note).toBe(code); } }); it("marks fork, half-holes, pinch, and bell without guessing outside the range", () => { expect(fingeringMarks(fingeringFor("F5", "baroque")!)).toContain("fork"); expect(fingeringMarks(fingeringFor("Eb5", "baroque")!)).toContain("half 6"); expect(fingeringMarks(fingeringFor("C#5", "baroque")!)).toContain("half 7"); expect(fingeringMarks(fingeringFor("E6", "baroque")!)).toContain("pinch"); expect(fingeringMarks(fingeringFor("C#7", "baroque")!)).toContain("cover bell"); expect(fingeringFor("B4", "baroque")).toBeNull(); expect(fingeringFor("E7", "baroque")).toBeNull(); expect(fingeringCode(fingeringFor("Db5", "baroque")!)).toBe(BAROQUE["C#5"]); }); it("applies only the cited German changes", () => { expect(fingeringCode(fingeringFor("F5", "german")!)).toBe("0 1 2 3 4"); expect(fingeringCode(fingeringFor("F#5", "german")!)).toBe("0 1 2 3 5 6a 6b 7a"); expect(fingeringCode(fingeringFor("Bb5", "german")!)).toBe("0 1 3 4 6a 6b"); expect(fingeringCode(fingeringFor("F6", "german")!)).toBe("x 1 2 3 4"); expect(fingeringCode(fingeringFor("G5", "german")!)).toBe(fingeringCode(fingeringFor("G5", "baroque")!)); expect(fingeringCode(fingeringFor("E6", "german")!)).toBe(fingeringCode(fingeringFor("E6", "baroque")!)); }); }); describe("sample melodies", () => { it("keeps Twinkle in 4/4 with forked F and full bars", () => { expect(twinkle.measures).toHaveLength(12); expect(twinkle.measures[0].events.map((event) => event.pitch)).toEqual(["C5", "C5", "G5", "G5"]); expect(twinkle.measures[2].events[0].pitch).toBe("F5"); for (const measure of twinkle.measures) { expect(barStatus(measure, twinkle.timeSignature).ok).toBe(true); } expect(formatTextTab(twinkle, "baroque")).toContain("0 1 2 3 4 6a 6b 7a 7b"); }); it("keeps Ode to Joy dotted rhythm inside the bar", () => { expect(expectedQuarters(odeToJoy.timeSignature)).toBe(4); const bar = odeToJoy.measures[3]; expect(bar.events.map((event) => event.label)).toEqual(["dotted quarter", "eighth", "half"]); expect(bar.events.reduce((sum, event) => sum + event.quarters, 0)).toBe(4); for (const measure of odeToJoy.measures) { expect(barStatus(measure, odeToJoy.timeSignature).ok).toBe(true); } }); }); describe("lead-line MusicXML", () => { const xml = readFileSync(path.join(__dirname, "fixtures", "lead.musicxml"), "utf8"); const parsed = parseMusicXml(xml); it("keeps timing, the top note, and the top staff", () => { expect(parsed.ok).toBe(true); if (!parsed.ok) return; expect(parsed.score.title).toBe("Probe"); expect(parsed.score.timeSignature).toEqual({ beats: 4, beatType: 4 }); const [first, second, third, fourth] = parsed.score.measures; expect(first.events.map((event) => [event.kind, event.pitch, event.label])).toEqual([ ["note", "E5", "quarter"], ["rest", null, "quarter"], ["note", "G5", "dotted quarter"], ["note", "A4", "eighth"], ]); expect(second.events.map((event) => [event.pitch, event.label, event.tied])).toEqual([ ["E5", "half", false], [null, "half", false], ]); expect(third.events.map((event) => event.pitch)).toEqual(["G5"]); expect(fourth.events.map((event) => event.pitch)).toEqual(["C6", "D6"]); expect(parsed.warnings.join(" ")).toMatch(/first part/); expect(parsed.warnings.join(" ")).toMatch(/lower staves/); expect(parsed.warnings.join(" ")).toMatch(/highest note/); expect(parsed.warnings.join(" ")).toMatch(/Grace notes/); }); });