Files
Recorder-Tabber/tests/converter.test.ts
T
Cursor Agentandchen e51fdf8216
AppImage release / appimage (push) Successful in 54s
Write the staff one octave below sounding pitch.
Sounding C5 sits on the ledger line below the treble staff. Sharps and flats move with the note. Fingerings and the foot-up charts stay as they are. Version 0.2.2.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-11 10:08:19 +00:00

207 lines
9.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
import { DURATION_PRESETS } from "@/lib/duration";
import {
clampLineBox,
cloneLine,
createNote,
createRest,
createSpace,
dropIndexFromPoint,
insertNote,
LINE_EXAMPLES,
moveNote,
neighborDuration,
removeNote,
setDuration,
} from "@/lib/line";
import {
ledgerIndexes,
noteValue,
restValue,
staffPlacement,
stemDown,
} from "@/lib/notation";
import { parsePitch } from "@/lib/pitch";
import { pngFilename, pngItemFromNote, pngLayout } from "@/lib/png-layout";
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);
});
it("inserts a rest and a space without changing the notes beside them", () => {
const note = createNote("C5", quarter, "c");
const rest = createRest(half, "r");
const space = createSpace(quarter, "s");
const line = insertNote(insertNote([note], 1, rest), 2, space);
expect(line.map((item) => item.kind)).toEqual(["note", "rest", "space"]);
expect(moveNote(line, "r", 0).map((item) => item.kind)).toEqual(["rest", "note", "space"]);
expect(setDuration(line, "s", half)[2]).toMatchObject({ kind: "space", quarters: 2, label: "half" });
expect(removeNote(line, "s").map((item) => item.kind)).toEqual(["note", "rest"]);
});
it("keeps a resized line box inside the page", () => {
expect(clampLineBox(10, 10, 800)).toEqual({ width: 280, height: 200 });
expect(clampLineBox(900, 2000, 640)).toEqual({ width: 640, height: 880 });
expect(clampLineBox(400, 360, 200)).toEqual({ width: 200, height: 360 });
});
it("lays out a PNG with a rest, a space, and a filename", () => {
const note = pngItemFromNote(createNote("G5", half, "g"));
const rest = pngItemFromNote(createRest(quarter, "r"));
const space = pngItemFromNote(createSpace(quarter, "s"));
expect(note.caption).toBe("G5");
expect(rest.kind).toBe("rest");
expect(space.glyphWidth).toBeGreaterThan(40);
expect(note.glyphHeight).toBeGreaterThan(note.glyphWidth);
const layout = pngLayout([note, rest, space], "Your line");
expect(layout.width).toBeGreaterThan(layout.slots[0].width + layout.slots[2].width);
expect(layout.height).toBeGreaterThan(layout.glyphHeight);
expect(pngFilename("Twinkle, opening")).toBe("twinkle-opening.png");
expect(pngFilename(" ")).toBe("line.png");
});
it("steps duration without skipping a preset", () => {
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
expect(neighborDuration(0.25, -1)).toBeNull();
expect(neighborDuration(4, 1)).toBeNull();
});
});
describe("treble staff", () => {
it("writes each sounding pitch one octave lower", () => {
expect(staffPlacement("C5")).toEqual({ index: -2, accidental: null });
expect(staffPlacement("C#5")).toEqual({ index: -2, accidental: "sharp" });
expect(staffPlacement("Eb5")).toEqual({ index: 0, accidental: "flat" });
expect(staffPlacement("C6")).toEqual({ index: 5, accidental: null });
expect(staffPlacement("C7")).toEqual({ index: 12, accidental: null });
expect(staffPlacement("D7")).toEqual({ index: 13, accidental: null });
expect(ledgerIndexes(-2)).toEqual([-2]);
expect(ledgerIndexes(5)).toEqual([]);
expect(ledgerIndexes(12)).toEqual([10, 12]);
expect(ledgerIndexes(13)).toEqual([10, 12]);
expect(stemDown(-2)).toBe(false);
expect(stemDown(5)).toBe(true);
});
it("writes length as noteheads, stems, flags, and dots", () => {
expect(noteValue(4)).toEqual({ head: "open", stem: false, flags: 0, dots: 0 });
expect(noteValue(2)).toEqual({ head: "open", stem: true, flags: 0, dots: 0 });
expect(noteValue(1.5)).toEqual({ head: "filled", stem: true, flags: 0, dots: 1 });
expect(noteValue(1)).toEqual({ head: "filled", stem: true, flags: 0, dots: 0 });
expect(noteValue(0.5)).toEqual({ head: "filled", stem: true, flags: 1, dots: 0 });
expect(noteValue(0.25)).toEqual({ head: "filled", stem: true, flags: 2, dots: 0 });
expect(restValue(2)).toEqual({ rest: "half", dots: 0 });
expect(restValue(3)).toEqual({ rest: "half", dots: 1 });
expect(restValue(0.5)).toEqual({ rest: "eighth", dots: 0 });
expect(restValue(0.25)).toEqual({ rest: "sixteenth", dots: 0 });
});
});