/** * Round-trip tests for the ABC parser and serializer. * * The grid rewrites the score, so a parse or spelling bug here silently * transposes the operator's music. The randomised block at the bottom is the * important half: the fixed cases only cover what I thought to write down, and * it was the random ones that found the accidental bugs. * * npm run test:abc */ import { unitsPerBar } from "@/lib/abc/model"; import type { AbcNote, AbcScore } from "@/lib/abc/model"; import { parseAbc } from "@/lib/abc/parse"; import { serializeAbc } from "@/lib/abc/serialize"; let fails = 0; const check = (label: string, ok: boolean, extra?: string) => { console.log((ok ? " ok " : " FAIL ") + label + (ok || !extra ? "" : "\n " + extra)); if (!ok) fails++; }; const SKELETON = `X:1 T:Your title M:4/4 L:1/8 K:G V:Vocal |: G2 A2 B2 d2 | e4 d4 :| V:Ins |: G,4 B,4 | C4 D4 :|`; const first = parseAbc(SKELETON); check("the skeleton parses", first.ok, first.ok ? "" : first.reason); if (!first.ok) process.exit(1); const s = first.score; check("two voices found", s.voices.length === 2 && s.voices[0].id === "Vocal" && s.voices[1].id === "Ins"); check("meter and unit read", s.meter[0] === 4 && s.meter[1] === 4 && s.unit[1] === 8); check("eight units to a bar", unitsPerBar(s) === 8); check("repeats were written out", s.repeatsExpanded); const vocal = s.notes.filter((n) => n.voice === 0).sort((a, b) => a.start - b.start); check("repeat doubled the vocal line", vocal.length === 12, `got ${vocal.length}`); check("the first pass spans two bars", vocal[5].start + vocal[5].duration === 16); check("G in the key of G is G4 = midi 67", vocal[0].midi === 67, `got ${vocal[0].midi}`); check("lower case d is an octave up from D", vocal[3].midi === 74, `got ${vocal[3].midi}`); check("the repeat starts where the first pass ended", vocal[6].start === 16, `got ${vocal[6].start}`); check( "the repeat replays the same pitches", vocal.slice(0, 6).map((n) => n.midi).join() === vocal.slice(6).map((n) => n.midi).join(), ); const ins = s.notes.filter((n) => n.voice === 1).sort((a, b) => a.start - b.start); check("G, is an octave below G", ins[0].midi === 55, `got ${ins[0].midi}`); // --- key signature and accidentals ------------------------------------------ const inG = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\nF4 f4 |"); check("the key signature sharpens F", inG.ok && inG.score.notes[0].midi === 66); const natural = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 F4 |"); check( "a natural cancels it for the rest of the bar", natural.ok && natural.score.notes[0].midi === 65 && natural.score.notes[1].midi === 65, ); const nextBar = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 z4 | F4 z4 |"); check("and the signature returns in the next bar", nextBar.ok && nextBar.score.notes[1].midi === 66); // --- lengths, ties, chords, rests ------------------------------------------- const lengths = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC C2 C/ C// C3/2 |"); check( "plain, doubled, halved, quartered and dotted lengths", lengths.ok && lengths.score.notes.map((n) => n.duration).join() === "1,2,0.5,0.25,1.5", ); const tied = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC4-C4 |"); check("a tie merges into one long note", tied.ok && tied.score.notes.length === 1 && tied.score.notes[0].duration === 8); const chord = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 |"); check( "a chord is three notes at one onset", chord.ok && chord.score.notes.length === 3 && chord.score.notes.every((n) => n.start === 0 && n.duration === 4), ); const rest = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nz4 C4 |"); check("a rest advances time without a note", rest.ok && rest.score.notes.length === 1 && rest.score.notes[0].start === 4); // --- refusals --------------------------------------------------------------- // Refusing beats dropping: the grid writes the score back out, so anything it // cannot draw is something the operator would lose without being told. for (const [label, src] of [ ["a tuplet", "X:1\nK:C\nV:1\n(3CDE |"], ["a grace note", "X:1\nK:C\nV:1\n{g}C4 |"], ] as const) { const r = parseAbc(src); check(`${label} is refused rather than dropped`, !r.ok && Boolean(r.offending)); } // --- chord symbols ---------------------------------------------------------- // These are not exotic. YuE2 writes them on ordinary output, and refusing a // score for containing one made the grid useless on real material. const harmonised = parseAbc('X:1\nM:4/4\nL:1/8\nQ:1/4=96\nK:E\nV:Lead\n"G#m" B2 c2 "E" e4 | "A" a8 |'); check("a score with chord symbols parses", harmonised.ok, harmonised.ok ? "" : harmonised.reason); if (harmonised.ok) { check("every chord symbol is kept", harmonised.score.marks.length === 3, `got ${harmonised.score.marks.length}`); check("the sharp minor survives verbatim", harmonised.score.marks[0].text === "G#m"); check("each one is pinned to its onset", harmonised.score.marks.map((m) => m.start).join() === "0,4,8"); check("they are chords, not annotations", harmonised.score.marks.every((m) => !m.annotation)); check("the tempo is read", harmonised.score.tempo === 96, `got ${harmonised.score.tempo}`); } const annotated = parseAbc('X:1\nM:4/4\nL:1/8\nK:C\nV:1\n"^softly" C4 "Dm" D4 |'); check( "a placement prefix marks an annotation, not a chord", annotated.ok && annotated.score.marks[0].annotation === true && annotated.score.marks[1].annotation === false, ); // --- round trip ------------------------------------------------------------- const fingerprint = (score: AbcScore) => [ ...score.notes.map((n) => `${n.voice}:${n.start}:${n.duration}:${n.midi}`), ...score.marks.map((m) => `mark ${m.voice}:${m.start}:${m.text}`), ] .sort() .join("|"); function roundTrip(label: string, src: string) { const a = parseAbc(src); if (!a.ok) return check(`${label} parses`, false, a.reason); const text = serializeAbc(a.score); const b = parseAbc(text); if (!b.ok) return check(`${label} re-parses`, false, `${b.reason}\n${text}`); const same = fingerprint(a.score) === fingerprint(b.score); check(`${label} survives a round trip`, same, same ? "" : `\n${text}\nwas ${fingerprint(a.score)}\nnow ${fingerprint(b.score)}`); } roundTrip("the skeleton", SKELETON); roundTrip("chords and rests", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 z2 D2 | C8 |"); roundTrip("accidentals", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^C2 _D2 =E2 ^^F2 |"); roundTrip("a natural after an accidental", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^F2 F2 =F2 F2 |"); roundTrip("naturals under a key signature", "X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F2 F2 =C2 C2 |"); roundTrip("wide octaves", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC,,2 C,2 C2 c2 | c'2 c''2 z4 |"); roundTrip("short notes", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC/ D/ E// F// G3/2 A/2 z2 |"); roundTrip("three four time", "X:1\nM:3/4\nL:1/8\nK:D\nV:1\nD2 F2 A2 | d6 |"); roundTrip("chord symbols", 'X:1\nM:4/4\nL:1/8\nK:E\nV:Lead\n"G#m" B2 c2 "E" e4 | "A" a8 |'); roundTrip("chord symbols on both voices", 'X:1\nM:4/4\nL:1/8\nK:C\nV:Vocal\n"C" C4 "F" F4 |\nV:Ins\n"C" C,8 |'); // The shape the real take had: chord symbols, repeats and two voices together. roundTrip( "a YuE2 shaped score", 'X:1\nT:Take\nM:4/4\nL:1/8\nQ:1/4=84\nK:E\nV:Vocal\n|: "G#m" B2 c2 B2 A2 | "E" e6 z2 :|\nV:Ins\n|: "G#m" E,4 G,4 | "E" E,8 :|', ); // --- randomised round trip --------------------------------------------------- function mulberry(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } let fuzzFails = 0; let firstReport = ""; for (let trial = 0; trial < 500; trial++) { const rand = mulberry(trial * 7919 + 13); const lengths = [0.5, 1, 1.5, 2, 3, 4, 6, 8]; const keys = ["C", "G", "D", "F", "Bb", "Am"]; const key = keys[Math.floor(rand() * keys.length)]; const notes: AbcNote[] = []; const voiceCount = 1 + Math.floor(rand() * 2); let id = 0; for (let v = 0; v < voiceCount; v++) { let t = 0; const count = 1 + Math.floor(rand() * 10); for (let i = 0; i < count; i++) { t += Math.floor(rand() * 3) * 0.5; const duration = lengths[Math.floor(rand() * lengths.length)]; notes.push({ id: `f${id++}`, voice: v, start: t, duration, midi: 36 + Math.floor(rand() * 48), tied: false }); t += duration; } } const score: AbcScore = { header: ["X:1", "M:4/4", "L:1/8", `K:${key}`], voices: Array.from({ length: voiceCount }, (_, i) => ({ id: String(i + 1), rest: "" })), notes, marks: [], meter: [4, 4], unit: [1, 8], key, repeatsExpanded: false, tempo: 120, }; const text = serializeAbc(score); const back = parseAbc(text); const want = fingerprint(score); const got = back.ok ? fingerprint(back.score) : "PARSE FAILED"; if (want !== got) { if (!fuzzFails) firstReport = `\n first mismatch, trial ${trial}, key ${key}\n${text}\nwant ${want}\ngot ${got}`; fuzzFails++; } } check("500 random scores round trip", fuzzFails === 0, `${fuzzFails} mismatched${firstReport}`); console.log(fails ? `\n${fails} FAILURES` : "\nall assertions passed"); process.exit(fails ? 1 : 0);