@k-l-lambda/lilylet 0.1.63 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/lib/lilylet/meiEncoder.js +58 -40
  2. package/lib/source/abc/abc.d.ts +102 -0
  3. package/lib/source/abc/abc.js +25 -0
  4. package/lib/source/abc/parser.d.ts +3 -0
  5. package/lib/source/abc/parser.js +6 -0
  6. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  7. package/lib/source/lilylet/abcDecoder.js +1035 -0
  8. package/lib/source/lilylet/index.d.ts +10 -0
  9. package/lib/source/lilylet/index.js +10 -0
  10. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  11. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  12. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  13. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  14. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  15. package/lib/source/lilylet/meiEncoder.js +1985 -0
  16. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  17. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  18. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  19. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  20. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  21. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  22. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  23. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  24. package/lib/source/lilylet/parser.d.ts +14 -0
  25. package/lib/source/lilylet/parser.js +161 -0
  26. package/lib/source/lilylet/serializer.d.ts +11 -0
  27. package/lib/source/lilylet/serializer.js +791 -0
  28. package/lib/source/lilylet/types.d.ts +253 -0
  29. package/lib/source/lilylet/types.js +100 -0
  30. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  31. package/lib/tests/abc-abcjs-parse.js +90 -0
  32. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  33. package/lib/tests/abc-abcjs-svg.js +143 -0
  34. package/lib/tests/abc-decoder.d.ts +1 -0
  35. package/lib/tests/abc-decoder.js +67 -0
  36. package/lib/tests/abc-mei-compare.d.ts +1 -0
  37. package/lib/tests/abc-mei-compare.js +525 -0
  38. package/lib/tests/auto-beam.d.ts +9 -0
  39. package/lib/tests/auto-beam.js +151 -0
  40. package/lib/tests/computeMeiHashes.d.ts +1 -0
  41. package/lib/tests/computeMeiHashes.js +87 -0
  42. package/lib/tests/encoder-mutation.d.ts +9 -0
  43. package/lib/tests/encoder-mutation.js +110 -0
  44. package/lib/tests/gpt-review-issues.d.ts +5 -0
  45. package/lib/tests/gpt-review-issues.js +255 -0
  46. package/lib/tests/json-to-lyl.d.ts +1 -0
  47. package/lib/tests/json-to-lyl.js +18 -0
  48. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  49. package/lib/tests/lilypond-roundtrip.js +558 -0
  50. package/lib/tests/lilypondDecoder.d.ts +6 -0
  51. package/lib/tests/lilypondDecoder.js +95 -0
  52. package/lib/tests/ly-to-lyl.d.ts +1 -0
  53. package/lib/tests/ly-to-lyl.js +12 -0
  54. package/lib/tests/mei.d.ts +1 -0
  55. package/lib/tests/mei.js +278 -0
  56. package/lib/tests/musicxml-decoder.d.ts +4 -0
  57. package/lib/tests/musicxml-decoder.js +61 -0
  58. package/lib/tests/musicxml-detail.d.ts +4 -0
  59. package/lib/tests/musicxml-detail.js +85 -0
  60. package/lib/tests/musicxml-fprod.d.ts +9 -0
  61. package/lib/tests/musicxml-fprod.js +153 -0
  62. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  63. package/lib/tests/musicxml-roundtrip.js +296 -0
  64. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  65. package/lib/tests/musicxml-to-mei.js +115 -0
  66. package/lib/tests/parser.d.ts +1 -0
  67. package/lib/tests/parser.js +17 -0
  68. package/lib/tests/render-k283.d.ts +1 -0
  69. package/lib/tests/render-k283.js +33 -0
  70. package/lib/tests/render-lyl.d.ts +1 -0
  71. package/lib/tests/render-lyl.js +35 -0
  72. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  73. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  74. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  75. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  76. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  77. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  78. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  79. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  80. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  81. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  82. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  83. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  84. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  85. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  86. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  87. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  88. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  89. package/lib/tests/unit/partialWarning.test.js +65 -0
  90. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  91. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  92. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  93. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  94. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  95. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  96. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  97. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  98. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  99. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  100. package/package.json +1 -1
  101. package/source/lilylet/meiEncoder.ts +65 -40
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit test: \change Staff handling in the LilyPond decoder.
3
+ *
4
+ * Regression guard for the bug where \change Staff commands are ignored during
5
+ * .ly → JSON decoding, causing notes that should be on staff 2 (bass) to remain
6
+ * on staff 1 (treble) in the lilylet output.
7
+ *
8
+ * Background: BWV-787 m1 fails because the decoder ignores all \change Staff
9
+ * commands (lilypondDecoder.ts: "Ignore \change Staff commands - staff is fixed
10
+ * per track"). Notes that cross staves get wrong tick assignments when
11
+ * regulateLilylet matches them against spartito events.
12
+ *
13
+ * Usage: npx tsx tests/unit/crossStaffDecoder.test.ts
14
+ */
15
+ import { decode } from "../../source/lilylet/lilypondDecoder.js";
16
+ import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
17
+ // ─── helpers ────────────────────────────────────────────────────────────────
18
+ let passed = 0;
19
+ let failed = 0;
20
+ function assert(condition, message) {
21
+ if (condition) {
22
+ console.log(` ✓ ${message}`);
23
+ passed++;
24
+ }
25
+ else {
26
+ console.error(` ✗ FAIL: ${message}`);
27
+ failed++;
28
+ }
29
+ }
30
+ function getNoteStaffs(voice) {
31
+ return voice.events
32
+ .filter(e => e.type === 'note')
33
+ .map(e => e.staff ?? voice.staff);
34
+ }
35
+ const LY_BOILERPLATE = `
36
+ \\version "2.22.0"
37
+ \\language "english"
38
+ \\header { tagline = ##f }
39
+ #(set-global-staff-size 20)
40
+ \\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
41
+ \\layout { \\context { \\Score autoBeaming = ##f } }
42
+ `;
43
+ // ─── Warm-up parser ──────────────────────────────────────────────────────────
44
+ {
45
+ const warn = console.warn, assert2 = console.assert;
46
+ console.warn = () => { };
47
+ console.assert = () => { };
48
+ try {
49
+ await decode('{ c }');
50
+ }
51
+ catch { /* ignore */ }
52
+ console.warn = warn;
53
+ console.assert = assert2;
54
+ }
55
+ // ─── Test input: two-staff piano, voice crosses from staff 1 to staff 2 ────
56
+ // Voice 1: c c (both on staff 1 / treble)
57
+ // Voice 2: g (starts on staff 2 / bass via \change Staff = "2")
58
+ // then g (returns to staff 1 via \change Staff = "1")
59
+ // Expected after decode:
60
+ // voice 2 events: g is on staff 2, second g is on staff 1
61
+ const LY_CROSS_STAFF = LY_BOILERPLATE + `
62
+ \\score {
63
+ \\new Staff = "1_1" <<
64
+ \\new Voice {
65
+ \\relative c' { \\clef treble \\time 4/4 c2 c } | % 1
66
+ }
67
+ \\new Voice {
68
+ \\relative c' { \\change Staff = "2" \\clef bass g2 \\change Staff = "1" \\stemDown g } | % 1
69
+ }
70
+ >>
71
+ \\layout { }
72
+ }
73
+ `;
74
+ // ─── Test 1: decoder captures \change Staff as context events in JSON ────────
75
+ console.log('\nTest 1: \\change Staff produces context { staff } events in decoded JSON');
76
+ await (async () => {
77
+ const doc = await decode(LY_CROSS_STAFF);
78
+ // Find the voice with cross-staff content (the one with \change Staff)
79
+ let crossVoice;
80
+ for (const measure of doc.measures) {
81
+ for (const part of measure.parts) {
82
+ for (const voice of part.voices) {
83
+ const staffContexts = voice.events.filter(e => e.type === 'context' && e.staff !== undefined);
84
+ if (staffContexts.length > 0) {
85
+ crossVoice = voice;
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ assert(crossVoice !== undefined, 'Found a voice containing context { staff } events');
92
+ if (crossVoice) {
93
+ const staffContexts = crossVoice.events.filter(e => e.type === 'context' && e.staff !== undefined);
94
+ assert(staffContexts.length >= 2, `Voice has ≥2 context staff events (got ${staffContexts.length}) — one for \change Staff = "2", one for \change Staff = "1"`);
95
+ }
96
+ })();
97
+ // ─── Test 2: notes after \change Staff = "2" are on staff 2 in the JSON ────
98
+ console.log('\nTest 2: note following \\change Staff = "2" has staff=2 in decoded JSON');
99
+ await (async () => {
100
+ const doc = await decode(LY_CROSS_STAFF);
101
+ let crossVoice;
102
+ for (const measure of doc.measures) {
103
+ for (const part of measure.parts) {
104
+ for (const voice of part.voices) {
105
+ const hasStaffContext = voice.events.some(e => e.type === 'context' && e.staff === 2);
106
+ if (hasStaffContext) {
107
+ crossVoice = voice;
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ if (!crossVoice) {
114
+ assert(false, 'Could not find cross-staff voice — context events missing from decoder output');
115
+ return;
116
+ }
117
+ // Walk voice events: track activeStaff, verify notes are on correct staff
118
+ let activeStaff = crossVoice.staff;
119
+ const noteStaffs = [];
120
+ for (const event of crossVoice.events) {
121
+ if (event.type === 'context' && event.staff) {
122
+ activeStaff = event.staff;
123
+ }
124
+ if (event.type === 'note') {
125
+ noteStaffs.push(event.staff ?? activeStaff);
126
+ }
127
+ }
128
+ assert(noteStaffs.length === 2, `Voice has 2 note events (got ${noteStaffs.length})`);
129
+ assert(noteStaffs[0] === 2, `First note (after \\change Staff = "2") is on staff 2 (got staff ${noteStaffs[0]})`);
130
+ assert(noteStaffs[1] === 1, `Second note (after \\change Staff = "1") is on staff 1 (got staff ${noteStaffs[1]})`);
131
+ })();
132
+ // ─── Test 3: serialized .lyl emits \staff "2" before the cross-staff note ───
133
+ console.log('\nTest 3: .lyl serialization emits \\staff "2" for cross-staff note');
134
+ await (async () => {
135
+ const doc = await decode(LY_CROSS_STAFF);
136
+ const lyl = serializeLilyletDoc(doc);
137
+ // The voice that started on staff 1 should switch to \staff "2" mid-line
138
+ assert(/\\staff "2"/.test(lyl), `lyl contains \\staff "2" switch: ${lyl.includes('\\staff "2"') ? 'yes' : 'NO'}`);
139
+ })();
140
+ // ─── Summary ─────────────────────────────────────────────────────────────────
141
+ console.log(`\n${'═'.repeat(40)}`);
142
+ if (failed > 0) {
143
+ console.log(`⚠️ ${failed} test(s) FAILED — \\change Staff is not handled by the decoder.`);
144
+ console.log(` See lilypondDecoder.ts: "Ignore \\change Staff commands - staff is fixed per track"`);
145
+ }
146
+ console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
147
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ // tests/unit/crossStaffEdgeCases.test.ts
2
+ //
3
+ // Adversarial edge cases for cross-staff carry-over fixes (written by GPT review).
4
+ // Run with:
5
+ // npx tsx tests/unit/crossStaffEdgeCases.test.ts
6
+ import { decode } from '../../source/lilylet/lilypondDecoder.js';
7
+ import { serializeLilyletDoc } from '../../source/lilylet/serializer.js';
8
+ let passed = 0;
9
+ let failed = 0;
10
+ function assert(condition, message) {
11
+ if (condition) {
12
+ console.log(` ✓ ${message}`);
13
+ passed++;
14
+ }
15
+ else {
16
+ console.error(` ✗ FAIL: ${message}`);
17
+ failed++;
18
+ }
19
+ }
20
+ function note(phonet, division = 4, octave = 0) {
21
+ return { type: 'note', pitches: [{ phonet, octave }], duration: { division, dots: 0 } };
22
+ }
23
+ function rest(division = 4) {
24
+ return { type: 'rest', duration: { division, dots: 0 } };
25
+ }
26
+ function mkDoc(events, staff = 1) {
27
+ return {
28
+ measures: [{
29
+ parts: [{
30
+ voices: [{ staff, events }]
31
+ }]
32
+ }]
33
+ };
34
+ }
35
+ function getMeasureVoice(doc, measureIndex) {
36
+ return doc.measures?.[measureIndex]?.parts?.[0]?.voices?.[0];
37
+ }
38
+ function staffContexts(events) {
39
+ return events.filter((e) => e.type === 'context' && e.staff != null);
40
+ }
41
+ function noteStaffs(voice) {
42
+ let activeStaff = voice.staff;
43
+ const out = [];
44
+ for (const e of voice.events) {
45
+ if (e.type === 'context' && e.staff != null)
46
+ activeStaff = e.staff;
47
+ else if (e.type === 'note')
48
+ out.push(e.staff ?? activeStaff);
49
+ }
50
+ return out;
51
+ }
52
+ function summarizeLeadTypesBeforeFirstStaffCtx(voice) {
53
+ const idx = voice.events.findIndex((e) => e.type === 'context' && e.staff != null);
54
+ if (idx < 0)
55
+ return [];
56
+ return voice.events.slice(0, idx).map((e) => e.type);
57
+ }
58
+ const LY_BOILERPLATE = `
59
+ \\version "2.22.0" \\language "english" \\header { tagline = ##f }
60
+ \\layout { \\context { \\Score autoBeaming = ##f } }
61
+ `;
62
+ /** Wrap a relative music expression in a minimal full LilyPond score. */
63
+ function makeLy(relativeMusic) {
64
+ return LY_BOILERPLATE + `
65
+ \\score { \\new Staff = "1_1" << \\new Voice {
66
+ \\relative c' { ${relativeMusic} }
67
+ } >> \\layout {} }
68
+ `;
69
+ }
70
+ console.log('crossStaffEdgeCases.test.ts\n');
71
+ // Warm-up
72
+ {
73
+ const w = console.warn, a = console.assert;
74
+ console.warn = () => { };
75
+ console.assert = () => { };
76
+ try {
77
+ await decode('{ c }');
78
+ }
79
+ catch { }
80
+ console.warn = w;
81
+ console.assert = a;
82
+ }
83
+ // ─── C1-A: music before reset — carry-over NOT suppressed ────────────────────
84
+ {
85
+ const LY = `\\time 2/4 c4 \\change Staff = "2" d4 | e4 \\change Staff = "1" f4`;
86
+ const doc = await decode(makeLy(LY));
87
+ const m2voice = getMeasureVoice(doc, 1);
88
+ assert(m2voice !== undefined, 'C1-A: decoded second measure voice exists');
89
+ const ctxs = staffContexts(m2voice.events);
90
+ assert(ctxs.length >= 2, 'C1-A: second measure has carry-over and explicit reset contexts');
91
+ const firstTwo = ctxs.slice(0, 2).map((e) => e.staff);
92
+ assert(JSON.stringify(firstTwo) === JSON.stringify([2, 1]), 'C1-A: measure 2 starts with carry-over staff 2, then reset to staff 1');
93
+ const staffs = noteStaffs(m2voice);
94
+ assert(JSON.stringify(staffs) === JSON.stringify([2, 1]), 'C1-A: e4 on staff 2 (carry-over), f4 on staff 1 (after reset)');
95
+ }
96
+ // ─── C1-B: non-musical context before reset — carry-over suppressed ──────────
97
+ // Strengthened: verifies explicit reset still present and all notes on staff 1.
98
+ {
99
+ const LY = `\\time 4/4 c4 \\change Staff = "2" d e f | \\time 3/4 \\change Staff = "1" g a b`;
100
+ const doc = await decode(makeLy(LY));
101
+ const m2voice = getMeasureVoice(doc, 1);
102
+ assert(m2voice !== undefined, 'C1-B: decoded second measure voice exists');
103
+ const ctxs = staffContexts(m2voice.events);
104
+ assert(ctxs.length >= 1, 'C1-B: second measure still has explicit reset to staff 1');
105
+ assert(ctxs[0].staff === 1, 'C1-B: first staff context in m2 is reset to 1, not ghost carry-over staff 2');
106
+ const staffs = noteStaffs(m2voice);
107
+ assert(staffs.length >= 3 && staffs.every(s => s === 1), 'C1-B: all notes in m2 are on staff 1 after immediate reset');
108
+ }
109
+ // ─── C1-C: grace before reset — discovery + carry-over kept ──────────────────
110
+ // Discovery assertion: prove grace is decoded as a pre-reset musical event.
111
+ {
112
+ const LY = `c4 \\change Staff = "2" d e f | \\grace g8 \\change Staff = "1" a b c`;
113
+ const doc = await decode(makeLy(LY));
114
+ const m2voice = getMeasureVoice(doc, 1);
115
+ assert(m2voice !== undefined, 'C1-C: decoded second measure voice exists');
116
+ // Discovery: find events between carry-over (context{staff:2}) and reset (context{staff:1})
117
+ const allCtxs = staffContexts(m2voice.events);
118
+ const carryIdx = m2voice.events.findIndex((e) => e.type === 'context' && e.staff === 2);
119
+ const resetIdx = m2voice.events.findIndex((e) => e.type === 'context' && e.staff === 1);
120
+ const betweenTypes = carryIdx >= 0 && resetIdx > carryIdx
121
+ ? m2voice.events.slice(carryIdx + 1, resetIdx).map((e) => e.type)
122
+ : [];
123
+ assert(betweenTypes.length > 0, `C1-C discovery: events between carry-over and reset (${betweenTypes.join(', ')})`);
124
+ const hasMusicalBetween = betweenTypes.some(t => /grace/i.test(t) || t === 'note' || t === 'tuplet');
125
+ assert(hasMusicalBetween, `C1-C discovery: musical event between carry-over and reset (${betweenTypes.join(', ')})`);
126
+ const ctxs = allCtxs;
127
+ assert(ctxs.length >= 2 && ctxs[0].staff === 2, 'C1-C: carry-over staff 2 is kept when musical event precedes explicit reset');
128
+ const m2lyl = serializeLilyletDoc(mkDoc(m2voice.events, m2voice.staff ?? 1));
129
+ assert(/\\staff "2"/.test(m2lyl), 'C1-C: serialized m2 contains \\staff "2" before the reset sequence');
130
+ }
131
+ // ─── C2-A: leading { staff:1, clef:"bass" } — clef NOT dropped ───────────────
132
+ {
133
+ const doc = mkDoc([
134
+ { type: 'context', staff: 1, clef: 'bass' },
135
+ note('c'),
136
+ ], 1);
137
+ const lyl = serializeLilyletDoc(doc);
138
+ assert(/\\clef\s+"?bass"?/.test(lyl), 'C2-A: same-staff compound context preserves clef');
139
+ assert(/[a-g]/.test(lyl), 'C2-A: note still serializes after same-staff compound context');
140
+ }
141
+ // ─── C2-A2: leading { staff:2, clef:"bass" } — both emitted, staff before clef
142
+ {
143
+ const doc = mkDoc([
144
+ { type: 'context', staff: 2, clef: 'bass' },
145
+ note('c'),
146
+ ], 1);
147
+ const lyl = serializeLilyletDoc(doc);
148
+ const iStaff = lyl.indexOf('\\staff "2"');
149
+ const iClef = lyl.search(/\\clef\s+"?bass"?/);
150
+ assert(iStaff >= 0, 'C2-A2: different-staff compound emits \\staff "2"');
151
+ assert(iClef >= 0, 'C2-A2: different-staff compound preserves clef');
152
+ assert(iStaff < iClef, 'C2-A2: \\staff "2" precedes \\clef in compound context');
153
+ }
154
+ // ─── C2-B: [staff:2, clef, staff:1, note] — correct order throughout ─────────
155
+ {
156
+ const doc = mkDoc([
157
+ { type: 'context', staff: 2 },
158
+ { type: 'context', clef: 'bass' },
159
+ { type: 'context', staff: 1 },
160
+ note('c'),
161
+ ], 1);
162
+ const lyl = serializeLilyletDoc(doc);
163
+ const i2 = lyl.indexOf('\\staff "2"');
164
+ const ic = lyl.search(/\\clef\s+"?bass"?/);
165
+ const i1 = lyl.indexOf('\\staff "1"');
166
+ // Find note after last \staff directive (to avoid matching letters in "bass"/"clef")
167
+ const inote = lyl.indexOf('c', i1 + 1);
168
+ assert(i2 >= 0, 'C2-B: emits \\staff "2"');
169
+ assert(ic >= 0, 'C2-B: emits \\clef bass');
170
+ assert(i1 >= 0, 'C2-B: emits \\staff "1"');
171
+ assert(i2 >= 0 && ic >= 0 && i1 >= 0 && inote >= 0 && i2 < ic && ic < i1 && i1 < inote, 'C2-B: order is \\staff "2" → clef → \\staff "1" → note');
172
+ }
173
+ // ─── C2-C: [pitchReset, staff:2, staff:1, rest] — no ghost, rest survives ────
174
+ {
175
+ const doc = mkDoc([
176
+ { type: 'pitchReset' },
177
+ { type: 'context', staff: 2 },
178
+ { type: 'context', staff: 1 },
179
+ rest(4),
180
+ ], 1);
181
+ const lyl = serializeLilyletDoc(doc);
182
+ assert(!/\\staff "2"/.test(lyl), 'C2-C: collapsed leading staff 2 does not appear as ghost');
183
+ assert(/\br/.test(lyl), 'C2-C: rest still serializes after collapsing leading staff events');
184
+ }
185
+ // ─── C2-D: unknown leading event stops scan — only if markup is supported ────
186
+ {
187
+ const markupEvent = { type: 'markup', content: 'hi', placement: 'above' };
188
+ const probeLyl = serializeLilyletDoc(mkDoc([markupEvent, note('c')], 1));
189
+ if (!/\\markup/.test(probeLyl)) {
190
+ console.log(' - SKIP C2-D: serializer does not emit \\markup for this event shape');
191
+ }
192
+ else {
193
+ const doc = mkDoc([
194
+ markupEvent,
195
+ { type: 'context', staff: 2 },
196
+ note('c'),
197
+ ], 1);
198
+ const lyl = serializeLilyletDoc(doc);
199
+ const iMarkup = lyl.indexOf('\\markup');
200
+ const iStaff2 = lyl.indexOf('\\staff "2"');
201
+ assert(iMarkup >= 0, 'C2-D: markup serializes');
202
+ assert(iStaff2 >= 0, 'C2-D: \\staff "2" serializes after markup');
203
+ assert(iMarkup < iStaff2, 'C2-D: markup precedes \\staff "2" — scan stopped by unknown event, staff not absorbed');
204
+ }
205
+ }
206
+ // ─── Summary ─────────────────────────────────────────────────────────────────
207
+ console.log(`\nPassed: ${passed}`);
208
+ console.log(`Failed: ${failed}`);
209
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Unit test: \change Staff state carries over across measure boundaries.
3
+ *
4
+ * Regression guard for the bug where the lyl serializer resets activeStaff
5
+ * to voice.staff at each measure boundary, so a voice that ended on staff 2
6
+ * (via \change Staff = "2") incorrectly starts the next measure on staff 1.
7
+ *
8
+ * Real-world case: BWV-787 %6 ends with \change Staff = "2".
9
+ * In %7, the voice starts on staff 2 (e4 on bass), then switches back
10
+ * with \change Staff = "1". The lyl serializer was outputting \staff "1"
11
+ * at the top of the %7 voice line, wrongly placing the bass e4 on staff 1.
12
+ *
13
+ * Usage: npx tsx tests/unit/crossStaffMultiMeasure.test.ts
14
+ */
15
+ export {};
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Unit test: \change Staff state carries over across measure boundaries.
3
+ *
4
+ * Regression guard for the bug where the lyl serializer resets activeStaff
5
+ * to voice.staff at each measure boundary, so a voice that ended on staff 2
6
+ * (via \change Staff = "2") incorrectly starts the next measure on staff 1.
7
+ *
8
+ * Real-world case: BWV-787 %6 ends with \change Staff = "2".
9
+ * In %7, the voice starts on staff 2 (e4 on bass), then switches back
10
+ * with \change Staff = "1". The lyl serializer was outputting \staff "1"
11
+ * at the top of the %7 voice line, wrongly placing the bass e4 on staff 1.
12
+ *
13
+ * Usage: npx tsx tests/unit/crossStaffMultiMeasure.test.ts
14
+ */
15
+ import { decode } from "../../source/lilylet/lilypondDecoder.js";
16
+ import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
17
+ import { parseCode } from "../../source/lilylet/parser.js";
18
+ // ─── helpers ────────────────────────────────────────────────────────────────
19
+ let passed = 0;
20
+ let failed = 0;
21
+ function assert(condition, message) {
22
+ if (condition) {
23
+ console.log(` ✓ ${message}`);
24
+ passed++;
25
+ }
26
+ else {
27
+ console.error(` ✗ FAIL: ${message}`);
28
+ failed++;
29
+ }
30
+ }
31
+ /** Walk voice events, tracking activeStaff via context { staff } events. */
32
+ function getNoteStaffs(voice) {
33
+ let activeStaff = voice.staff;
34
+ const result = [];
35
+ for (const e of voice.events) {
36
+ if (e.type === 'context' && e.staff)
37
+ activeStaff = e.staff;
38
+ if (e.type === 'note')
39
+ result.push({ phonet: e.pitches[0].phonet, staff: activeStaff });
40
+ }
41
+ return result;
42
+ }
43
+ const LY_BOILERPLATE = `
44
+ \\version "2.22.0"
45
+ \\language "english"
46
+ \\header { tagline = ##f }
47
+ #(set-global-staff-size 20)
48
+ \\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
49
+ \\layout { \\context { \\Score autoBeaming = ##f } }
50
+ `;
51
+ // Two-measure test case:
52
+ // Measure 1: voice on staff 1 (treble), switches to staff 2 at the end
53
+ // Measure 2: voice continues on staff 2 (carry-over), then switches back to staff 1
54
+ //
55
+ // Measure 1 notes (staff 1 → staff 2):
56
+ // c4 d4 \change Staff = "2" e4 f4
57
+ // → c,d on staff 1; e,f on staff 2
58
+ //
59
+ // Measure 2 notes (starts on staff 2, then back to staff 1):
60
+ // g4 \change Staff = "1" a4 b4 c4
61
+ // → g on staff 2; a,b,c on staff 1
62
+ const LY_CROSS_MEASURE = LY_BOILERPLATE + `
63
+ \\score {
64
+ \\new PianoStaff <<
65
+ \\new Staff = "1" {
66
+ \\relative c' { \\clef treble \\time 4/4
67
+ c1 | c1 |
68
+ }
69
+ }
70
+ \\new Staff = "2" {
71
+ \\relative c { \\clef bass \\time 4/4
72
+ c1 | c1 |
73
+ }
74
+ }
75
+ >>
76
+ \\new Staff = "1_1" <<
77
+ \\new Voice {
78
+ \\relative c' { \\clef treble \\time 4/4
79
+ c4 d4 \\change Staff = "2" e4 f4 | % 1
80
+ g4 \\change Staff = "1" a4 b4 c4 | % 2
81
+ }
82
+ }
83
+ >>
84
+ \\layout { }
85
+ }
86
+ `;
87
+ // ─── Warm-up ────────────────────────────────────────────────────────────────
88
+ {
89
+ const warn = console.warn, assert2 = console.assert;
90
+ console.warn = () => { };
91
+ console.assert = () => { };
92
+ try {
93
+ await decode('{ c }');
94
+ }
95
+ catch { /* ignore */ }
96
+ console.warn = warn;
97
+ console.assert = assert2;
98
+ }
99
+ // ─── Test 1: decoder captures staff changes across measures ─────────────────
100
+ console.log('\nTest 1: decoder — cross-measure \\change Staff captured in JSON');
101
+ await (async () => {
102
+ const doc = await decode(LY_CROSS_MEASURE);
103
+ // Find the cross-staff voice (the one that has context { staff } events)
104
+ let crossVoice1; // measure 1
105
+ let crossVoice2; // measure 2
106
+ for (let mi = 0; mi < doc.measures.length; mi++) {
107
+ for (const part of doc.measures[mi].parts) {
108
+ for (const v of part.voices) {
109
+ if (v.events.some(e => e.type === 'context' && e.staff && e.staff !== v.staff)) {
110
+ if (mi === 0)
111
+ crossVoice1 = v;
112
+ if (mi === 1)
113
+ crossVoice2 = v;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ assert(crossVoice1 !== undefined, 'Measure 1: cross-staff voice found');
119
+ assert(crossVoice2 !== undefined, 'Measure 2: cross-staff voice found (staff carry-over)');
120
+ if (crossVoice1) {
121
+ const notes1 = getNoteStaffs(crossVoice1);
122
+ assert(notes1.length === 4, `Measure 1: 4 notes (got ${notes1.length})`);
123
+ assert(notes1[0]?.staff === 1 && notes1[1]?.staff === 1, `Measure 1: c,d on staff 1 (got ${notes1[0]?.staff},${notes1[1]?.staff})`);
124
+ assert(notes1[2]?.staff === 2 && notes1[3]?.staff === 2, `Measure 1: e,f on staff 2 (got ${notes1[2]?.staff},${notes1[3]?.staff})`);
125
+ }
126
+ if (crossVoice2) {
127
+ const notes2 = getNoteStaffs(crossVoice2);
128
+ assert(notes2.length === 4, `Measure 2: 4 notes (got ${notes2.length})`);
129
+ // g should be on staff 2 (carry-over from measure 1's \change Staff = "2")
130
+ assert(notes2[0]?.staff === 2, `Measure 2: g on staff 2 (carry-over) (got ${notes2[0]?.staff})`);
131
+ assert(notes2[1]?.staff === 1 && notes2[2]?.staff === 1 && notes2[3]?.staff === 1, `Measure 2: a,b,c on staff 1 (got ${notes2[1]?.staff},${notes2[2]?.staff},${notes2[3]?.staff})`);
132
+ }
133
+ })();
134
+ // ─── Test 2: serialized .lyl preserves staff on g (measure 2 first note) ────
135
+ console.log('\nTest 2: serializer — g in measure 2 is on \\staff "2" in .lyl');
136
+ await (async () => {
137
+ const doc = await decode(LY_CROSS_MEASURE);
138
+ const lyl = serializeLilyletDoc(doc);
139
+ console.log(' lyl output:\n' + lyl.split('\n').map(l => ' ' + l).join('\n'));
140
+ // In the .lyl for measure 2, the voice line that contains g should start
141
+ // with \staff "2" (or have \staff "2" before g), NOT \staff "1".
142
+ //
143
+ // Correct: \staff "2" g4 \staff "1" a4 b4 c4
144
+ // Wrong: \staff "1" g4 \staff "1" a4 b4 c4 (or just \staff "1" g4 a4 b4 c4)
145
+ // Parse back and check
146
+ const docRT = parseCode(lyl);
147
+ let crossVoice2;
148
+ for (const part of docRT.measures[1]?.parts ?? []) {
149
+ for (const v of part.voices) {
150
+ if (v.events.some(e => e.type === 'note' && e.pitches[0].phonet === 'g')) {
151
+ crossVoice2 = v;
152
+ }
153
+ }
154
+ }
155
+ assert(crossVoice2 !== undefined, 'Measure 2 round-trip: voice with g found');
156
+ if (crossVoice2) {
157
+ const notes2 = getNoteStaffs(crossVoice2);
158
+ const gNote = notes2.find(n => n.phonet === 'g');
159
+ assert(gNote?.staff === 2, `Measure 2 round-trip: g is on staff 2 (got ${gNote?.staff})`);
160
+ const aNotes = notes2.filter(n => ['a', 'b', 'c'].includes(n.phonet));
161
+ assert(aNotes.every(n => n.staff === 1), `Measure 2 round-trip: a,b,c are on staff 1 (got ${aNotes.map(n => n.staff).join(',')})`);
162
+ }
163
+ })();
164
+ // ─── Test 3: no redundant consecutive \staff directives ──────────────────────
165
+ // Regression: the carry-over fix prepends context { staff: 2 } to measure 2's
166
+ // voice, but serializeVoice also emits \staff "1" (voice.staff) at the top.
167
+ // This produces "\staff "1" \staff "2" g4" — the first directive is redundant
168
+ // because it is immediately overridden by the carry-over.
169
+ //
170
+ // Expected: \staff "2" g4 \staff "1" a b c (or just the carry-over first)
171
+ // Buggy: \staff "1" \staff "2" g4 ... (two consecutive \staff)
172
+ console.log('\nTest 3: no redundant consecutive \\staff directives in serialized .lyl');
173
+ await (async () => {
174
+ const doc = await decode(LY_CROSS_MEASURE);
175
+ const lyl = serializeLilyletDoc(doc);
176
+ // Check each measure line for consecutive \staff "N" \staff "M" pattern
177
+ for (const line of lyl.split('\n')) {
178
+ const consecutiveStaff = /\\staff\s+"[^"]+"\s+\\staff\s+"[^"]+"/.test(line);
179
+ assert(!consecutiveStaff, `No consecutive \\staff directives on line: ${line.trim()}`);
180
+ }
181
+ })();
182
+ // ─── Test 4: carry-over immediately cancelled by \change Staff — no ghost \staff
183
+ //
184
+ // Real-world case: czerny-740-31.ly m68, voice (staff:1):
185
+ // events = [context{staff:2}, context{staff:1}, rest, ...]
186
+ //
187
+ // The carry-over prepends context{staff:2} but the very first event of the
188
+ // original measure is \change Staff = "1" — switching straight back.
189
+ // There are no notes, clef changes, or ottava shifts between the two staff
190
+ // switches.
191
+ //
192
+ // Correct output: \staff "1" r4 ... (carry-over cancelled, only show final staff)
193
+ // Buggy output: \staff "2" \time 3/4 \staff "1" r4 ...
194
+ console.log('\nTest 4: carry-over immediately cancelled by \\change Staff — no ghost \\staff emitted');
195
+ await (async () => {
196
+ // Build a doc where voice.staff=1 has a carry-over to staff 2 that is
197
+ // immediately cancelled by context{staff:1}.
198
+ const { parseCode } = await import('../../source/lilylet/parser.js');
199
+ const doc = {
200
+ measures: [{
201
+ timeSig: { numerator: 3, denominator: 4 },
202
+ parts: [{
203
+ voices: [{
204
+ staff: 1,
205
+ events: [
206
+ { type: 'context', staff: 2 }, // carry-over
207
+ { type: 'context', staff: 1 }, // immediate cancel
208
+ { type: 'rest', duration: { division: 4, dots: 0 } },
209
+ { type: 'note', pitches: [{ phonet: 'c', octave: 1 }], duration: { division: 2, dots: 0 } },
210
+ ]
211
+ }]
212
+ }]
213
+ }]
214
+ };
215
+ const lyl = serializeLilyletDoc(doc);
216
+ // The ghost \staff "2" must not appear — it was immediately cancelled
217
+ assert(!/\\staff "2"/.test(lyl), `No ghost \\staff "2" when carry-over immediately cancelled — got: ${lyl.trim()}`);
218
+ // \staff "1" may or may not appear (acceptable either way), but the notes must be on staff 1
219
+ const docRT = parseCode(lyl);
220
+ const voices = docRT.measures[0]?.parts[0]?.voices ?? [];
221
+ const staffOnes = voices.filter(v => v.staff === 1);
222
+ assert(staffOnes.length > 0, `Round-trip: voice on staff 1 exists`);
223
+ const notes = staffOnes.flatMap(v => v.events.filter(e => e.type === 'note' || e.type === 'rest'));
224
+ assert(notes.length === 2, `Round-trip: 2 note/rest events on staff 1 (got ${notes.length})`);
225
+ })();
226
+ // ─── Summary ─────────────────────────────────────────────────────────────────
227
+ console.log(`\n${'═'.repeat(40)}`);
228
+ if (failed > 0)
229
+ console.log(`⚠️ ${failed} FAILED — \\change Staff state not carried across measure boundaries`);
230
+ console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
231
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Unit test: full-measure rest (R1) detection in the LilyPond decoder.
3
+ *
4
+ * Regression guard for:
5
+ * - lilypondDecoder: R must decode with fullMeasure=true; r must not
6
+ * - serializeLilyletDoc: fullMeasure rest must serialize as uppercase R
7
+ * - parseCode round-trip: R in .lyl must parse back with fullMeasure=true
8
+ *
9
+ * Usage: npx tsx tests/unit/fullMeasureRest.test.ts
10
+ */
11
+ export {};