AppImage release / appimage (push) Successful in 54s
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>
207 lines
9.1 KiB
TypeScript
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 });
|
|
});
|
|
});
|