@k-l-lambda/lilylet 0.1.71 → 0.1.73
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.
- package/lib/highlight.d.ts +1 -0
- package/lib/highlight.js +1 -0
- package/lib/lilylet/highlight.d.ts +29 -0
- package/lib/lilylet/highlight.js +145 -0
- package/package.json +8 -2
- package/source/lilylet/highlight.ts +192 -0
- package/lib/source/abc/abc.d.ts +0 -102
- package/lib/source/abc/abc.js +0 -25
- package/lib/source/abc/parser.d.ts +0 -3
- package/lib/source/abc/parser.js +0 -6
- package/lib/source/lilylet/abcDecoder.d.ts +0 -25
- package/lib/source/lilylet/abcDecoder.js +0 -1035
- package/lib/source/lilylet/index.d.ts +0 -10
- package/lib/source/lilylet/index.js +0 -10
- package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
- package/lib/source/lilylet/lilypondDecoder.js +0 -1223
- package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
- package/lib/source/lilylet/lilypondEncoder.js +0 -893
- package/lib/source/lilylet/meiEncoder.d.ts +0 -8
- package/lib/source/lilylet/meiEncoder.js +0 -1985
- package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
- package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
- package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
- package/lib/source/lilylet/musicXmlEncoder.js +0 -701
- package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
- package/lib/source/lilylet/musicXmlTypes.js +0 -7
- package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
- package/lib/source/lilylet/musicXmlUtils.js +0 -469
- package/lib/source/lilylet/parser.d.ts +0 -14
- package/lib/source/lilylet/parser.js +0 -161
- package/lib/source/lilylet/serializer.d.ts +0 -11
- package/lib/source/lilylet/serializer.js +0 -791
- package/lib/source/lilylet/types.d.ts +0 -253
- package/lib/source/lilylet/types.js +0 -100
- package/lib/tests/abc-abcjs-parse.d.ts +0 -8
- package/lib/tests/abc-abcjs-parse.js +0 -90
- package/lib/tests/abc-abcjs-svg.d.ts +0 -1
- package/lib/tests/abc-abcjs-svg.js +0 -143
- package/lib/tests/abc-decoder.d.ts +0 -1
- package/lib/tests/abc-decoder.js +0 -67
- package/lib/tests/abc-mei-compare.d.ts +0 -1
- package/lib/tests/abc-mei-compare.js +0 -525
- package/lib/tests/auto-beam.d.ts +0 -9
- package/lib/tests/auto-beam.js +0 -151
- package/lib/tests/computeMeiHashes.d.ts +0 -1
- package/lib/tests/computeMeiHashes.js +0 -87
- package/lib/tests/encoder-mutation.d.ts +0 -9
- package/lib/tests/encoder-mutation.js +0 -110
- package/lib/tests/gpt-review-issues.d.ts +0 -5
- package/lib/tests/gpt-review-issues.js +0 -255
- package/lib/tests/json-to-lyl.d.ts +0 -1
- package/lib/tests/json-to-lyl.js +0 -18
- package/lib/tests/lilypond-roundtrip.d.ts +0 -7
- package/lib/tests/lilypond-roundtrip.js +0 -558
- package/lib/tests/lilypondDecoder.d.ts +0 -6
- package/lib/tests/lilypondDecoder.js +0 -95
- package/lib/tests/ly-to-lyl.d.ts +0 -1
- package/lib/tests/ly-to-lyl.js +0 -12
- package/lib/tests/mei.d.ts +0 -1
- package/lib/tests/mei.js +0 -278
- package/lib/tests/musicxml-decoder.d.ts +0 -4
- package/lib/tests/musicxml-decoder.js +0 -61
- package/lib/tests/musicxml-detail.d.ts +0 -4
- package/lib/tests/musicxml-detail.js +0 -85
- package/lib/tests/musicxml-fprod.d.ts +0 -9
- package/lib/tests/musicxml-fprod.js +0 -153
- package/lib/tests/musicxml-roundtrip.d.ts +0 -7
- package/lib/tests/musicxml-roundtrip.js +0 -296
- package/lib/tests/musicxml-to-mei.d.ts +0 -6
- package/lib/tests/musicxml-to-mei.js +0 -115
- package/lib/tests/parser.d.ts +0 -1
- package/lib/tests/parser.js +0 -17
- package/lib/tests/render-k283.d.ts +0 -1
- package/lib/tests/render-k283.js +0 -33
- package/lib/tests/render-lyl.d.ts +0 -1
- package/lib/tests/render-lyl.js +0 -35
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
- package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
- package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
- package/lib/tests/unit/gptReviewIssues.test.js +0 -240
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
- package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
- package/lib/tests/unit/partialWarning.test.d.ts +0 -4
- package/lib/tests/unit/partialWarning.test.js +0 -65
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
- package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
- package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
- package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
- package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
- 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,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);
|