@k-l-lambda/lilylet 0.1.71 → 0.1.72

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 (104) hide show
  1. package/lib/highlight.d.ts +1 -0
  2. package/lib/highlight.js +1 -0
  3. package/lib/lilylet/highlight.d.ts +29 -0
  4. package/lib/lilylet/highlight.js +145 -0
  5. package/package.json +8 -2
  6. package/source/lilylet/highlight.ts +192 -0
  7. package/lib/source/abc/abc.d.ts +0 -102
  8. package/lib/source/abc/abc.js +0 -25
  9. package/lib/source/abc/parser.d.ts +0 -3
  10. package/lib/source/abc/parser.js +0 -6
  11. package/lib/source/lilylet/abcDecoder.d.ts +0 -25
  12. package/lib/source/lilylet/abcDecoder.js +0 -1035
  13. package/lib/source/lilylet/index.d.ts +0 -10
  14. package/lib/source/lilylet/index.js +0 -10
  15. package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
  16. package/lib/source/lilylet/lilypondDecoder.js +0 -1223
  17. package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
  18. package/lib/source/lilylet/lilypondEncoder.js +0 -893
  19. package/lib/source/lilylet/meiEncoder.d.ts +0 -8
  20. package/lib/source/lilylet/meiEncoder.js +0 -1985
  21. package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
  22. package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
  23. package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
  24. package/lib/source/lilylet/musicXmlEncoder.js +0 -701
  25. package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
  26. package/lib/source/lilylet/musicXmlTypes.js +0 -7
  27. package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
  28. package/lib/source/lilylet/musicXmlUtils.js +0 -469
  29. package/lib/source/lilylet/parser.d.ts +0 -14
  30. package/lib/source/lilylet/parser.js +0 -161
  31. package/lib/source/lilylet/serializer.d.ts +0 -11
  32. package/lib/source/lilylet/serializer.js +0 -791
  33. package/lib/source/lilylet/types.d.ts +0 -253
  34. package/lib/source/lilylet/types.js +0 -100
  35. package/lib/tests/abc-abcjs-parse.d.ts +0 -8
  36. package/lib/tests/abc-abcjs-parse.js +0 -90
  37. package/lib/tests/abc-abcjs-svg.d.ts +0 -1
  38. package/lib/tests/abc-abcjs-svg.js +0 -143
  39. package/lib/tests/abc-decoder.d.ts +0 -1
  40. package/lib/tests/abc-decoder.js +0 -67
  41. package/lib/tests/abc-mei-compare.d.ts +0 -1
  42. package/lib/tests/abc-mei-compare.js +0 -525
  43. package/lib/tests/auto-beam.d.ts +0 -9
  44. package/lib/tests/auto-beam.js +0 -151
  45. package/lib/tests/computeMeiHashes.d.ts +0 -1
  46. package/lib/tests/computeMeiHashes.js +0 -87
  47. package/lib/tests/encoder-mutation.d.ts +0 -9
  48. package/lib/tests/encoder-mutation.js +0 -110
  49. package/lib/tests/gpt-review-issues.d.ts +0 -5
  50. package/lib/tests/gpt-review-issues.js +0 -255
  51. package/lib/tests/json-to-lyl.d.ts +0 -1
  52. package/lib/tests/json-to-lyl.js +0 -18
  53. package/lib/tests/lilypond-roundtrip.d.ts +0 -7
  54. package/lib/tests/lilypond-roundtrip.js +0 -558
  55. package/lib/tests/lilypondDecoder.d.ts +0 -6
  56. package/lib/tests/lilypondDecoder.js +0 -95
  57. package/lib/tests/ly-to-lyl.d.ts +0 -1
  58. package/lib/tests/ly-to-lyl.js +0 -12
  59. package/lib/tests/mei.d.ts +0 -1
  60. package/lib/tests/mei.js +0 -278
  61. package/lib/tests/musicxml-decoder.d.ts +0 -4
  62. package/lib/tests/musicxml-decoder.js +0 -61
  63. package/lib/tests/musicxml-detail.d.ts +0 -4
  64. package/lib/tests/musicxml-detail.js +0 -85
  65. package/lib/tests/musicxml-fprod.d.ts +0 -9
  66. package/lib/tests/musicxml-fprod.js +0 -153
  67. package/lib/tests/musicxml-roundtrip.d.ts +0 -7
  68. package/lib/tests/musicxml-roundtrip.js +0 -296
  69. package/lib/tests/musicxml-to-mei.d.ts +0 -6
  70. package/lib/tests/musicxml-to-mei.js +0 -115
  71. package/lib/tests/parser.d.ts +0 -1
  72. package/lib/tests/parser.js +0 -17
  73. package/lib/tests/render-k283.d.ts +0 -1
  74. package/lib/tests/render-k283.js +0 -33
  75. package/lib/tests/render-lyl.d.ts +0 -1
  76. package/lib/tests/render-lyl.js +0 -35
  77. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
  78. package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
  79. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
  80. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
  81. package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
  82. package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
  83. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
  84. package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
  85. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
  86. package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
  87. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
  88. package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
  89. package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
  90. package/lib/tests/unit/gptReviewIssues.test.js +0 -240
  91. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
  92. package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
  93. package/lib/tests/unit/partialWarning.test.d.ts +0 -4
  94. package/lib/tests/unit/partialWarning.test.js +0 -65
  95. package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
  96. package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
  97. package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
  98. package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
  99. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
  100. package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
  101. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
  102. package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
  103. package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
  104. package/lib/tests/unit/voiceStaffParsing.test.js +0 -118
@@ -1,65 +0,0 @@
1
- /**
2
- * Unit tests for \partial syntax — duration check and warning emission.
3
- */
4
- import { parseCode, getParseWarnings } from '../../source/lilylet/index.js';
5
- let passed = 0;
6
- let failed = 0;
7
- const test = (name, fn) => {
8
- try {
9
- fn();
10
- console.log(` ✓ ${name}`);
11
- passed++;
12
- }
13
- catch (e) {
14
- console.log(` ✗ ${name}: ${e}`);
15
- failed++;
16
- }
17
- };
18
- const assert = (cond, msg) => {
19
- if (!cond)
20
- throw new Error(msg);
21
- };
22
- console.log('\n\\partial warning tests\n' + '─'.repeat(40));
23
- // ── 1. Correct usage: \partial 8 with one eighth note ─────────────────────
24
- parseCode('\\staff "1" \\time 2/4 \\partial 8 b8 | %1\n\\staff "1" \\time 2/4 c4 c | %2');
25
- test('no warning when partial matches voice duration (eighth)', () => {
26
- const w = getParseWarnings();
27
- assert(w.length === 0, `expected 0 warnings, got ${w.length}: ${JSON.stringify(w)}`);
28
- });
29
- // ── 2. Mismatch: \partial 8 but voice has a quarter note (480 ≠ 240) ──────
30
- parseCode('\\staff "1" \\time 2/4 \\partial 8 c4 | %1\n\\staff "1" \\time 2/4 c4 c | %2');
31
- test('warning when partial declares 8 but voice has quarter note', () => {
32
- const w = getParseWarnings();
33
- assert(w.length === 1, `expected 1 warning, got ${w.length}`);
34
- assert(w[0].type === 'partial-mismatch', 'wrong type');
35
- assert(w[0].declared === 240, `declared=${w[0].declared}`);
36
- assert(w[0].actual === 480, `actual=${w[0].actual}`);
37
- });
38
- // ── 3. Dotted partial: \partial 4. with c4 c8 (720 ticks each) ───────────
39
- parseCode('\\staff "1" \\time 3/4 \\partial 4. c4 c8 | %1\n\\staff "1" \\time 3/4 c4 c c | %2');
40
- test('no warning for dotted \partial 4. with matching 720-tick voice', () => {
41
- const w = getParseWarnings();
42
- assert(w.length === 0, `expected 0 warnings, got ${w.length}`);
43
- });
44
- // ── 4. Partial stored as context event and measure.partial set ────────────
45
- const doc = parseCode('\\staff "1" \\time 2/4 \\partial 8 b8 | %1\n\\staff "1" \\time 2/4 c4 c | %2');
46
- test('\\partial creates context event with partial duration', () => {
47
- const voice = doc.measures[0].parts[0].voices[0];
48
- const partialCtx = voice.events.find(e => e.type === 'context' && e.partial);
49
- assert(!!partialCtx, 'no partial context event found');
50
- assert(partialCtx.partial.division === 8, 'wrong division');
51
- assert((partialCtx.partial.dots || 0) === 0, 'wrong dots');
52
- });
53
- test('measure.partial is true when \\partial is declared', () => {
54
- assert(doc.measures[0].partial === true, 'measure.partial not true');
55
- assert(!doc.measures[1].partial, 'second measure should not be partial');
56
- });
57
- // ── 5. No warning for spacer-only voice ───────────────────────────────────
58
- parseCode('\\staff "1" \\time 2/4 \\partial 8 b8 \\\\\n\\staff "1" s8 | %1\n\\staff "1" \\time 2/4 c4 c | %2');
59
- test('no warning for spacer-only voice even if it has different ticks', () => {
60
- const w = getParseWarnings();
61
- assert(w.length === 0, `expected 0 warnings, got ${w.length}`);
62
- });
63
- console.log('\n' + '─'.repeat(40));
64
- console.log(`Results: ${passed} passed, ${failed} failed`);
65
- process.exit(failed > 0 ? 1 : 0);
@@ -1,8 +0,0 @@
1
- /**
2
- * Round-trip test for Lilylet serializer
3
- *
4
- * Loads LilyletDoc JSON -> serializes to .lyl -> parses back -> compares
5
- *
6
- * Usage: npx ts-node tests/unit/serializerRoundTrip.test.ts [json-dir] [max-files]
7
- */
8
- export {};
@@ -1,263 +0,0 @@
1
- /**
2
- * Round-trip test for Lilylet serializer
3
- *
4
- * Loads LilyletDoc JSON -> serializes to .lyl -> parses back -> compares
5
- *
6
- * Usage: npx ts-node tests/unit/serializerRoundTrip.test.ts [json-dir] [max-files]
7
- */
8
- import * as fs from 'fs';
9
- import * as path from 'path';
10
- import { parseCode } from "../../source/lilylet/parser.js";
11
- import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
12
- // Get args
13
- const args = process.argv.slice(2);
14
- const JSON_DIR = args[0] || './tests/output/from-ly';
15
- const MAX_FILES = parseInt(args[1] || '0', 10); // 0 = all files
16
- /**
17
- * Check if a context change event is effectively empty
18
- * (created by \staff command which doesn't set any context properties)
19
- */
20
- const isEmptyContextChange = (event) => {
21
- if (event.type !== 'context')
22
- return false;
23
- const ctx = event;
24
- return !ctx.clef && !ctx.key && !ctx.time && !ctx.tempo &&
25
- ctx.ottava === undefined && !ctx.stemDirection;
26
- };
27
- /**
28
- * Check if a context change only has key or time (from measure-level serialization)
29
- */
30
- const isKeyOrTimeOnlyContext = (event) => {
31
- if (event.type !== 'context')
32
- return false;
33
- const ctx = event;
34
- // Only has key or time, nothing else
35
- const hasKey = !!ctx.key;
36
- const hasTime = !!ctx.time;
37
- const hasOther = !!ctx.clef || !!ctx.tempo || ctx.ottava !== undefined || !!ctx.stemDirection;
38
- return (hasKey || hasTime) && !hasOther;
39
- };
40
- /**
41
- * Check if an event is a space rest (invisible rest, used for empty measures)
42
- */
43
- const isSpaceRest = (event) => {
44
- if (event.type !== 'rest')
45
- return false;
46
- const rest = event;
47
- return !!rest.invisible;
48
- };
49
- /**
50
- * Filter out empty context changes and key/time-only context changes from events array
51
- * Key/time context changes may come from measure-level properties during serialization
52
- * Also filters space rests that were added to preserve empty measures
53
- */
54
- const filterEvents = (events, isFirstMeasure = false, filterSpaceRests = false) => {
55
- return events.filter(e => {
56
- if (isEmptyContextChange(e))
57
- return false;
58
- // In first measure, key/time context events may come from measure properties
59
- if (isFirstMeasure && isKeyOrTimeOnlyContext(e))
60
- return false;
61
- // Filter space rests if requested (for comparing with originally empty voices)
62
- if (filterSpaceRests && isSpaceRest(e))
63
- return false;
64
- return true;
65
- });
66
- };
67
- /**
68
- * Deep comparison of two LilyletDoc objects
69
- * Returns list of differences found
70
- */
71
- const compareDocs = (original, roundTrip) => {
72
- const diffs = [];
73
- // Compare measure count
74
- if (original.measures.length !== roundTrip.measures.length) {
75
- diffs.push(`Measure count: ${original.measures.length} vs ${roundTrip.measures.length}`);
76
- return diffs; // Can't compare further if measure counts differ
77
- }
78
- // Compare each measure
79
- for (let m = 0; m < original.measures.length; m++) {
80
- const origMeasure = original.measures[m];
81
- const rtMeasure = roundTrip.measures[m];
82
- // Compare parts count
83
- if (origMeasure.parts.length !== rtMeasure.parts.length) {
84
- diffs.push(`Measure ${m + 1}: part count ${origMeasure.parts.length} vs ${rtMeasure.parts.length}`);
85
- continue;
86
- }
87
- // Compare each part
88
- for (let p = 0; p < origMeasure.parts.length; p++) {
89
- const origPart = origMeasure.parts[p];
90
- const rtPart = rtMeasure.parts[p];
91
- // Compare voice count
92
- if (origPart.voices.length !== rtPart.voices.length) {
93
- diffs.push(`Measure ${m + 1}, Part ${p + 1}: voice count ${origPart.voices.length} vs ${rtPart.voices.length}`);
94
- continue;
95
- }
96
- // Compare each voice
97
- for (let v = 0; v < origPart.voices.length; v++) {
98
- const origVoice = origPart.voices[v];
99
- const rtVoice = rtPart.voices[v];
100
- // Filter out empty context changes (created by \staff command)
101
- // Also filter key/time context in first measure (from measure properties)
102
- const isFirstMeasure = m === 0;
103
- // If original voice was empty, filter space rests from round-trip
104
- // (they were added to preserve empty measures)
105
- const origWasEmpty = origVoice.events.length === 0;
106
- const origEvents = filterEvents(origVoice.events, isFirstMeasure, false);
107
- const rtEvents = filterEvents(rtVoice.events, isFirstMeasure, origWasEmpty);
108
- // Compare staff (skip for empty voices - staff number is semantically irrelevant)
109
- const voiceIsEmpty = origEvents.length === 0 && rtEvents.length === 0;
110
- if (origVoice.staff !== rtVoice.staff && !voiceIsEmpty) {
111
- diffs.push(`Measure ${m + 1}, Part ${p + 1}, Voice ${v + 1}: staff ${origVoice.staff} vs ${rtVoice.staff}`);
112
- }
113
- // Compare event count
114
- if (origEvents.length !== rtEvents.length) {
115
- diffs.push(`Measure ${m + 1}, Part ${p + 1}, Voice ${v + 1}: event count ${origEvents.length} vs ${rtEvents.length}`);
116
- continue;
117
- }
118
- // Compare each event
119
- for (let e = 0; e < origEvents.length; e++) {
120
- const origEvent = origEvents[e];
121
- const rtEvent = rtEvents[e];
122
- const eventDiffs = compareEvents(origEvent, rtEvent, `M${m + 1}P${p + 1}V${v + 1}E${e + 1}`);
123
- diffs.push(...eventDiffs);
124
- }
125
- }
126
- }
127
- }
128
- return diffs;
129
- };
130
- /**
131
- * Compare two events
132
- */
133
- const compareEvents = (orig, rt, location) => {
134
- const diffs = [];
135
- // Compare type
136
- if (orig.type !== rt.type) {
137
- diffs.push(`${location}: type ${orig.type} vs ${rt.type}`);
138
- return diffs;
139
- }
140
- if (orig.type === 'note' && rt.type === 'note') {
141
- const origNote = orig;
142
- const rtNote = rt;
143
- // Compare pitch count
144
- if (origNote.pitches.length !== rtNote.pitches.length) {
145
- diffs.push(`${location}: pitch count ${origNote.pitches.length} vs ${rtNote.pitches.length}`);
146
- }
147
- else {
148
- // Compare each pitch
149
- for (let i = 0; i < origNote.pitches.length; i++) {
150
- const origPitch = origNote.pitches[i];
151
- const rtPitch = rtNote.pitches[i];
152
- if (origPitch.phonet !== rtPitch.phonet) {
153
- diffs.push(`${location}: pitch[${i}] phonet ${origPitch.phonet} vs ${rtPitch.phonet}`);
154
- }
155
- if (origPitch.octave !== rtPitch.octave) {
156
- diffs.push(`${location}: pitch[${i}] octave ${origPitch.octave} vs ${rtPitch.octave}`);
157
- }
158
- // Compare accidentals (normalize undefined to no accidental)
159
- const origAcc = origPitch.accidental || null;
160
- const rtAcc = rtPitch.accidental || null;
161
- if (origAcc !== rtAcc) {
162
- diffs.push(`${location}: pitch[${i}] accidental ${origAcc} vs ${rtAcc}`);
163
- }
164
- }
165
- }
166
- // Compare duration
167
- if (origNote.duration.division !== rtNote.duration.division) {
168
- diffs.push(`${location}: duration division ${origNote.duration.division} vs ${rtNote.duration.division}`);
169
- }
170
- if (origNote.duration.dots !== rtNote.duration.dots) {
171
- diffs.push(`${location}: duration dots ${origNote.duration.dots} vs ${rtNote.duration.dots}`);
172
- }
173
- }
174
- if (orig.type === 'rest' && rt.type === 'rest') {
175
- const origRest = orig;
176
- const rtRest = rt;
177
- // Compare duration
178
- if (origRest.duration.division !== rtRest.duration.division) {
179
- diffs.push(`${location}: duration division ${origRest.duration.division} vs ${rtRest.duration.division}`);
180
- }
181
- if (origRest.duration.dots !== rtRest.duration.dots) {
182
- diffs.push(`${location}: duration dots ${origRest.duration.dots} vs ${rtRest.duration.dots}`);
183
- }
184
- // Compare rest type flags
185
- if (!!origRest.invisible !== !!rtRest.invisible) {
186
- diffs.push(`${location}: invisible ${origRest.invisible} vs ${rtRest.invisible}`);
187
- }
188
- if (!!origRest.fullMeasure !== !!rtRest.fullMeasure) {
189
- diffs.push(`${location}: fullMeasure ${origRest.fullMeasure} vs ${rtRest.fullMeasure}`);
190
- }
191
- }
192
- return diffs;
193
- };
194
- /**
195
- * Run round-trip test on a single JSON file
196
- */
197
- const testRoundTrip = (jsonPath) => {
198
- // Load original JSON
199
- const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
200
- const original = JSON.parse(jsonContent);
201
- // Serialize to .lyl
202
- const lylContent = serializeLilyletDoc(original);
203
- // Parse back to LilyletDoc
204
- const roundTrip = parseCode(lylContent);
205
- // Compare
206
- const diffs = compareDocs(original, roundTrip);
207
- return {
208
- success: diffs.length === 0,
209
- diffs,
210
- lylLength: lylContent.length,
211
- };
212
- };
213
- const main = async () => {
214
- console.log(`Round-trip test: JSON -> .lyl -> JSON`);
215
- console.log(`JSON directory: ${JSON_DIR}\n`);
216
- // Find JSON files (exclude _summary.json)
217
- let jsonFiles = fs.readdirSync(JSON_DIR)
218
- .filter(f => f.endsWith('.json') && !f.startsWith('_'))
219
- .map(f => path.join(JSON_DIR, f));
220
- if (MAX_FILES > 0) {
221
- jsonFiles = jsonFiles.slice(0, MAX_FILES);
222
- }
223
- console.log(`Found ${jsonFiles.length} JSON files to test\n`);
224
- let passed = 0;
225
- let failed = 0;
226
- const failures = [];
227
- for (let i = 0; i < jsonFiles.length; i++) {
228
- const jsonPath = jsonFiles[i];
229
- const filename = path.basename(jsonPath);
230
- try {
231
- const result = testRoundTrip(jsonPath);
232
- if (result.success) {
233
- console.log(`[${i + 1}/${jsonFiles.length}] ✓ ${filename} (${result.lylLength} chars)`);
234
- passed++;
235
- }
236
- else {
237
- console.log(`[${i + 1}/${jsonFiles.length}] ✗ ${filename} (${result.diffs.length} diffs)`);
238
- failed++;
239
- failures.push({ file: filename, diffs: result.diffs.slice(0, 5) }); // Keep first 5 diffs
240
- }
241
- }
242
- catch (e) {
243
- console.log(`[${i + 1}/${jsonFiles.length}] ✗ ${filename}: ${e.message.slice(0, 80)}`);
244
- failed++;
245
- failures.push({ file: filename, diffs: [e.message.slice(0, 200)] });
246
- }
247
- }
248
- console.log('\n========================================');
249
- console.log(`Total: ${jsonFiles.length}, Passed: ${passed}, Failed: ${failed}`);
250
- // Show failure details
251
- if (failures.length > 0) {
252
- console.log('\n--- Failure Details (first 10) ---');
253
- for (const f of failures.slice(0, 10)) {
254
- console.log(`\n${f.file}:`);
255
- for (const diff of f.diffs) {
256
- console.log(` - ${diff}`);
257
- }
258
- }
259
- }
260
- // Exit with error code if there are failures
261
- process.exit(failed > 0 ? 1 : 0);
262
- };
263
- main().catch(console.error);
@@ -1,25 +0,0 @@
1
- /**
2
- * Unit test: \staff "N" inside \tuplet / \times blocks is preserved in the AST.
3
- *
4
- * Regression guard for the bug in grammar.jison.js where tuplet/times event
5
- * construction filters events to only 'note' | 'rest', silently dropping any
6
- * 'context' events (including \staff "N") that appear inside the block.
7
- *
8
- * Real-world case: Chopin Op.28 No.1 (chopin--chopin-28-1.lyl) — the bass voice
9
- * uses cross-staff beaming where melody notes are physically in the treble range.
10
- * Placing \staff "1" inside a \tuplet block to switch staff mid-tuplet has no
11
- * effect because the parser drops the context event before expandVoice ever sees
12
- * it. intelli-piano/spartitoMeasure.ts expandVoice already contains correct
13
- * handling for context events inside tuplets, but it is unreachable due to this
14
- * parser bug.
15
- *
16
- * Bug location: source/lilylet/grammar.jison.js lines ~344-347
17
- * timesEvent: $$[$0-1].filter(e => e.type === 'note' || e.type === 'rest')
18
- * tupletEvent: $$[$0-1].filter(e => e.type === 'note' || e.type === 'rest')
19
- *
20
- * Fix: extend the filter to also keep 'context' events:
21
- * .filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context')
22
- *
23
- * Usage: npx tsx tests/unit/staffInsideTuplet.test.ts
24
- */
25
- export {};
@@ -1,133 +0,0 @@
1
- /**
2
- * Unit test: \staff "N" inside \tuplet / \times blocks is preserved in the AST.
3
- *
4
- * Regression guard for the bug in grammar.jison.js where tuplet/times event
5
- * construction filters events to only 'note' | 'rest', silently dropping any
6
- * 'context' events (including \staff "N") that appear inside the block.
7
- *
8
- * Real-world case: Chopin Op.28 No.1 (chopin--chopin-28-1.lyl) — the bass voice
9
- * uses cross-staff beaming where melody notes are physically in the treble range.
10
- * Placing \staff "1" inside a \tuplet block to switch staff mid-tuplet has no
11
- * effect because the parser drops the context event before expandVoice ever sees
12
- * it. intelli-piano/spartitoMeasure.ts expandVoice already contains correct
13
- * handling for context events inside tuplets, but it is unreachable due to this
14
- * parser bug.
15
- *
16
- * Bug location: source/lilylet/grammar.jison.js lines ~344-347
17
- * timesEvent: $$[$0-1].filter(e => e.type === 'note' || e.type === 'rest')
18
- * tupletEvent: $$[$0-1].filter(e => e.type === 'note' || e.type === 'rest')
19
- *
20
- * Fix: extend the filter to also keep 'context' events:
21
- * .filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context')
22
- *
23
- * Usage: npx tsx tests/unit/staffInsideTuplet.test.ts
24
- */
25
- import { parseCode } from "../../source/lilylet/parser.js";
26
- // ─── helpers ────────────────────────────────────────────────────────────────
27
- let passed = 0;
28
- let failed = 0;
29
- function assert(condition, message) {
30
- if (condition) {
31
- console.log(` ✓ ${message}`);
32
- passed++;
33
- }
34
- else {
35
- console.error(` ✗ FAIL: ${message}`);
36
- failed++;
37
- }
38
- }
39
- /**
40
- * Walk voice events (including inside tuplets), tracking activeStaff from
41
- * context { staff } events. Returns [{phonet, staff}] for each note.
42
- */
43
- function getNoteStaffs(voice) {
44
- let activeStaff = voice.staff;
45
- const result = [];
46
- function walkEvents(events) {
47
- for (const e of events) {
48
- if (e.type === 'context' && e.staff)
49
- activeStaff = e.staff;
50
- else if (e.type === 'note')
51
- result.push({ phonet: e.pitches[0].phonet, staff: activeStaff });
52
- else if (e.type === 'tuplet' || e.type === 'times')
53
- walkEvents(e.events || []);
54
- }
55
- }
56
- walkEvents(voice.events);
57
- return result;
58
- }
59
- /**
60
- * Find a tuplet/times event in a voice and return its events array.
61
- */
62
- function getTupletEvents(voice) {
63
- for (const e of voice.events) {
64
- if (e.type === 'tuplet' || e.type === 'times')
65
- return e.events || [];
66
- }
67
- return null;
68
- }
69
- // ─── test 1: \staff "N" inside \tuplet ──────────────────────────────────────
70
- console.log('\nTest 1: \\staff "N" inside \\tuplet 3/2');
71
- console.log('─'.repeat(50));
72
- // A single voice: starts on staff 2, then \staff "1" inside the tuplet
73
- // switches to staff 1 for notes g and c.
74
- // a (staff=2), g (staff=1), c (staff=1)
75
- const LYL_TUPLET = `
76
- \\staff "2" \\tuplet 3/2 { a16 \\staff "1" g c } |
77
- `;
78
- {
79
- const doc = parseCode(LYL_TUPLET);
80
- const voice = doc.measures[0]?.parts[0]?.voices[0];
81
- assert(!!voice, 'measure 0 voice 0 exists');
82
- const tupletEvents = getTupletEvents(voice);
83
- assert(tupletEvents !== null, 'voice contains a tuplet event');
84
- // The bug: context event is filtered out — tupletEvents has no 'context'
85
- const hasContextEvent = tupletEvents?.some(e => e.type === 'context' && e.staff === 1) ?? false;
86
- assert(hasContextEvent, 'tuplet events array contains context { staff: 1 }');
87
- const noteStaffs = getNoteStaffs(voice);
88
- assert(noteStaffs.length === 3, `3 notes found (got ${noteStaffs.length})`);
89
- assert(noteStaffs[0]?.phonet === 'a' && noteStaffs[0]?.staff === 2, `note "a" on staff 2 (got staff=${noteStaffs[0]?.staff})`);
90
- assert(noteStaffs[1]?.phonet === 'g' && noteStaffs[1]?.staff === 1, `note "g" on staff 1 after \\staff "1" (got staff=${noteStaffs[1]?.staff})`);
91
- assert(noteStaffs[2]?.phonet === 'c' && noteStaffs[2]?.staff === 1, `note "c" on staff 1 (got staff=${noteStaffs[2]?.staff})`);
92
- }
93
- // ─── test 2: \staff "N" inside \times ───────────────────────────────────────
94
- console.log('\nTest 2: \\staff "N" inside \\times 2/3');
95
- console.log('─'.repeat(50));
96
- const LYL_TIMES = `
97
- \\staff "2" \\times 2/3 { a16 \\staff "1" g c } |
98
- `;
99
- {
100
- const doc = parseCode(LYL_TIMES);
101
- const voice = doc.measures[0]?.parts[0]?.voices[0];
102
- assert(!!voice, 'measure 0 voice 0 exists');
103
- const tupletEvents = getTupletEvents(voice);
104
- assert(tupletEvents !== null, 'voice contains a times event');
105
- const hasContextEvent = tupletEvents?.some(e => e.type === 'context' && e.staff === 1) ?? false;
106
- assert(hasContextEvent, 'times events array contains context { staff: 1 }');
107
- const noteStaffs = getNoteStaffs(voice);
108
- assert(noteStaffs.length === 3, `3 notes found (got ${noteStaffs.length})`);
109
- assert(noteStaffs[1]?.phonet === 'g' && noteStaffs[1]?.staff === 1, `note "g" on staff 1 after \\staff "1" (got staff=${noteStaffs[1]?.staff})`);
110
- }
111
- // ─── test 3: \staff "N" at voice level before tuplet (should already work) ──
112
- console.log('\nTest 3: \\staff "N" at voice level before \\tuplet (baseline)');
113
- console.log('─'.repeat(50));
114
- // \staff "1" at voice level — should already work
115
- const LYL_VOICE_LEVEL = `
116
- \\staff "2" \\staff "1" \\tuplet 3/2 { a16 g c } |
117
- `;
118
- {
119
- const doc = parseCode(LYL_VOICE_LEVEL);
120
- const voice = doc.measures[0]?.parts[0]?.voices[0];
121
- assert(!!voice, 'measure 0 voice 0 exists');
122
- // Voice-level context events are NOT inside the tuplet
123
- const hasVoiceLevelContext = voice.events.some(e => e.type === 'context' && e.staff === 1);
124
- assert(hasVoiceLevelContext, 'voice has a context { staff: 1 } event at voice level');
125
- const noteStaffs = getNoteStaffs(voice);
126
- assert(noteStaffs.length === 3, `3 notes found`);
127
- assert(noteStaffs[0]?.staff === 1, `all notes on staff 1 (got ${noteStaffs[0]?.staff})`);
128
- }
129
- // ─── summary ────────────────────────────────────────────────────────────────
130
- console.log('');
131
- console.log(`Results: ${passed} passed, ${failed} failed`);
132
- if (failed > 0)
133
- process.exit(1);
@@ -1,16 +0,0 @@
1
- /**
2
- * Regression test: first note of a \times tuplet must NOT escape outside the
3
- * tuplet block when there are context events (e.g. \tempo, dynamics) between
4
- * the first and second note.
5
- *
6
- * Bug: serializer emitted the first note before the \times 4/6 { } wrapper when
7
- * that note was followed by a \tempo or dynamic context change inside the tuplet.
8
- *
9
- * Minimal reproduction from chopin--chopin-25-11.ly measure 5:
10
- * \times 4/6 { f'''16 _\f \tempo ... c16 e a, ds c }
11
- * incorrectly serialized as:
12
- * f'''16(\f[ \tempo ... \times 4/6 { c16 e a, ds c } ← only 5 notes!
13
- *
14
- * Usage: npx tsx tests/unit/timesFirstNoteEscape.test.ts
15
- */
16
- export {};
@@ -1,152 +0,0 @@
1
- /**
2
- * Regression test: first note of a \times tuplet must NOT escape outside the
3
- * tuplet block when there are context events (e.g. \tempo, dynamics) between
4
- * the first and second note.
5
- *
6
- * Bug: serializer emitted the first note before the \times 4/6 { } wrapper when
7
- * that note was followed by a \tempo or dynamic context change inside the tuplet.
8
- *
9
- * Minimal reproduction from chopin--chopin-25-11.ly measure 5:
10
- * \times 4/6 { f'''16 _\f \tempo ... c16 e a, ds c }
11
- * incorrectly serialized as:
12
- * f'''16(\f[ \tempo ... \times 4/6 { c16 e a, ds c } ← only 5 notes!
13
- *
14
- * Usage: npx tsx tests/unit/timesFirstNoteEscape.test.ts
15
- */
16
- import { decode } from "../../source/lilylet/lilypondDecoder.js";
17
- import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
18
- import { parseCode } from "../../source/lilylet/parser.js";
19
- // ─── helpers ────────────────────────────────────────────────────────────────
20
- let passed = 0;
21
- let failed = 0;
22
- function assert(condition, message) {
23
- if (condition) {
24
- console.log(` ✓ ${message}`);
25
- passed++;
26
- }
27
- else {
28
- console.error(` ✗ FAIL: ${message}`);
29
- failed++;
30
- }
31
- }
32
- function countNotesInTuplets(lyl) {
33
- // Count notes that appear inside \times N/M { } blocks
34
- // A note outside would appear before \times
35
- const doc = parseCode(lyl);
36
- let count = 0;
37
- for (const m of doc.measures) {
38
- for (const p of m.parts) {
39
- for (const v of p.voices) {
40
- for (const e of v.events) {
41
- if (e.type === 'times' || e.type === 'tuplet') {
42
- count += (e.events ?? []).filter((te) => te.type === 'note').length;
43
- }
44
- }
45
- }
46
- }
47
- }
48
- return count;
49
- }
50
- const LY_BOILERPLATE = `
51
- \\version "2.22.0"
52
- \\language "english"
53
- \\header { tagline = ##f }
54
- #(set-global-staff-size 20)
55
- \\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
56
- \\layout { \\context { \\Score autoBeaming = ##f } }
57
- `;
58
- // Warm-up
59
- {
60
- const w = console.warn, a = console.assert;
61
- console.warn = () => { };
62
- console.assert = () => { };
63
- try {
64
- await decode('{ c }');
65
- }
66
- catch { }
67
- console.warn = w;
68
- console.assert = a;
69
- }
70
- // ─── Test 1: \tempo inside tuplet — first note must stay inside ─────────────
71
- console.log('\nTest 1: \\tempo inside \\times tuplet — all 6 notes must be in tuplet');
72
- const LY_TEMPO_IN_TUPLET = LY_BOILERPLATE + `
73
- \\score {
74
- \\new Staff = "1_1" <<
75
- \\new Voice {
76
- \\relative c'' {
77
- \\time 4/4 \\clef treble
78
- \\once \\omit TupletNumber \\times 4/6 {
79
- f'''16 \\tempo "Allegro" 2=69 c16 e16 a,16 ds16 c16
80
- }
81
- r2. | % 1
82
- }
83
- }
84
- >>
85
- \\layout { }
86
- }
87
- `;
88
- await (async () => {
89
- const doc = await decode(LY_TEMPO_IN_TUPLET);
90
- const lyl = serializeLilyletDoc(doc);
91
- // Count notes inside tuplets in serialized lyl
92
- const notesInTuplet = countNotesInTuplets(lyl);
93
- assert(notesInTuplet === 6, `All 6 notes inside tuplet (got ${notesInTuplet}) — first note must not escape before \\times`);
94
- // Also verify no note appears before \times in lyl text
95
- // The lyl should not start a measure with a note before \times
96
- const firstMeasureLine = lyl.split('\n').find(l => l.includes('f\'\'\''));
97
- if (firstMeasureLine) {
98
- const timesIdx = firstMeasureLine.indexOf('\\times');
99
- const fIdx = firstMeasureLine.indexOf("f'''");
100
- assert(timesIdx < fIdx || timesIdx === -1, `\\times appears before f''' in serialized line (timesIdx=${timesIdx} fIdx=${fIdx})`);
101
- }
102
- console.log(` lyl: ${lyl.split('\n').find(l => l.includes("f'''") || l.includes('\\times')) ?? '(not found)'}`);
103
- })();
104
- // ─── Test 2: dynamic (_\f) inside tuplet — first note must stay inside ──────
105
- console.log('\nTest 2: dynamic \\f inside \\times tuplet — all 4 notes must be in tuplet');
106
- const LY_DYN_IN_TUPLET = LY_BOILERPLATE + `
107
- \\score {
108
- \\new Staff = "1_1" <<
109
- \\new Voice {
110
- \\relative c' {
111
- \\time 4/4 \\clef treble
112
- \\times 2/3 { c8\\f d e } \\times 2/3 { f8 g a } r2 | % 1
113
- }
114
- }
115
- >>
116
- \\layout { }
117
- }
118
- `;
119
- await (async () => {
120
- const doc = await decode(LY_DYN_IN_TUPLET);
121
- const lyl = serializeLilyletDoc(doc);
122
- const notesInTuplet = countNotesInTuplets(lyl);
123
- assert(notesInTuplet === 6, `All 6 notes (3+3) inside tuplets (got ${notesInTuplet})`);
124
- })();
125
- // ─── Test 3: round-trip — tuplet note count preserved ────────────────────────
126
- console.log('\nTest 3: round-trip note count inside \\times preserved');
127
- await (async () => {
128
- const doc = await decode(LY_TEMPO_IN_TUPLET);
129
- const lyl = serializeLilyletDoc(doc);
130
- const docRT = parseCode(lyl);
131
- let totalNotes = 0;
132
- for (const m of docRT.measures) {
133
- for (const p of m.parts) {
134
- for (const v of p.voices) {
135
- for (const e of v.events) {
136
- if (e.type === 'times' || e.type === 'tuplet') {
137
- totalNotes += (e.events ?? []).filter((te) => te.type === 'note').length;
138
- }
139
- if (e.type === 'note')
140
- totalNotes++; // notes outside tuplets
141
- }
142
- }
143
- }
144
- }
145
- assert(totalNotes >= 6, `Round-trip: at least 6 notes present (got ${totalNotes})`);
146
- })();
147
- // ─── Summary ─────────────────────────────────────────────────────────────────
148
- console.log(`\n${'═'.repeat(40)}`);
149
- if (failed > 0)
150
- console.log(`⚠️ ${failed} FAILED — first note of \\times tuplet escaping outside`);
151
- console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
152
- process.exit(failed > 0 ? 1 : 0);