Files
Recorder-Tabber/tests/converter.test.ts
T
Cursor Agentandchen b354ea9f88
AppImage release / appimage (push) Successful in 53s
Replace sheet reading with a fingering editor.
Drag Baroque charts onto a line, reorder them, set a duration, or remove a note. The AppImage no longer packages oemer, ONNX, or the models. Version 0.2.0.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-11 09:06:33 +00:00

126 lines
5.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
import { DURATION_PRESETS } from "@/lib/duration";
import {
cloneLine,
createNote,
dropIndexFromPoint,
insertNote,
LINE_EXAMPLES,
moveNote,
removeNote,
setDuration,
} from "@/lib/line";
import { parsePitch } from "@/lib/pitch";
import { extraBeatArrows } from "@/lib/score";
const BAROQUE: Record<string, string> = {
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("keeps the cited German changes beside the Baroque chart", () => {
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("fingering line", () => {
const quarter = DURATION_PRESETS.find((preset) => preset.label === "quarter")!;
const half = DURATION_PRESETS.find((preset) => preset.label === "half")!;
it("inserts, reorders, removes, and changes duration without touching other notes", () => {
const c = createNote("C5", quarter, "c");
const e = createNote("E5", quarter, "e");
const g = createNote("G5", quarter, "g");
const inserted = insertNote(insertNote([c], 1, g), 1, e);
expect(inserted.map((note) => note.pitch)).toEqual(["C5", "E5", "G5"]);
expect(moveNote(inserted, "c", 3).map((note) => note.pitch)).toEqual(["E5", "G5", "C5"]);
expect(moveNote(inserted, "g", 0).map((note) => note.pitch)).toEqual(["G5", "C5", "E5"]);
expect(moveNote(inserted, "e", 1).map((note) => note.id)).toEqual(["c", "e", "g"]);
const held = setDuration(inserted, "e", half);
expect(held.find((note) => note.id === "e")).toMatchObject({ quarters: 2, label: "half" });
expect(held.find((note) => note.id === "c")?.quarters).toBe(1);
expect(removeNote(held, "e").map((note) => note.pitch)).toEqual(["C5", "G5"]);
});
it("places a drop before the note whose center is to the right of the pointer", () => {
const line = { left: 0, right: 400, top: 0, bottom: 200 };
expect(dropIndexFromPoint(20, 40, line, [50, 150, 250])).toBe(0);
expect(dropIndexFromPoint(80, 40, line, [50, 150, 250])).toBe(1);
expect(dropIndexFromPoint(300, 40, line, [50, 150, 250])).toBe(3);
expect(dropIndexFromPoint(20, 400, line, [50])).toBe(-1);
expect(dropIndexFromPoint(10, 10, line, [])).toBe(0);
});
it("offers two short editable examples and an empty start", () => {
expect(LINE_EXAMPLES).toHaveLength(2);
for (const example of LINE_EXAMPLES) {
expect(example.notes.length).toBeGreaterThan(0);
expect(example.notes.length).toBeLessThanOrEqual(8);
for (const note of example.notes) {
expect(fingeringFor(note.pitch, "baroque")).not.toBeNull();
}
}
const copy = cloneLine([...LINE_EXAMPLES[0].notes]);
expect(copy.map((note) => note.pitch)).toEqual(LINE_EXAMPLES[0].notes.map((note) => note.pitch));
expect(copy[0].id).not.toBe(LINE_EXAMPLES[0].notes[0].id);
expect(extraBeatArrows(LINE_EXAMPLES[0].notes[6].quarters, { beats: 4, beatType: 4 })).toBe(1);
expect(extraBeatArrows(LINE_EXAMPLES[1].notes[0].quarters, { beats: 4, beatType: 4 })).toBe(0);
});
});