@k-l-lambda/lilylet 0.1.70 → 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.
- package/lib/gmInstruments.d.ts +1 -0
- package/lib/gmInstruments.js +1 -0
- package/lib/highlight.d.ts +1 -0
- package/lib/highlight.js +1 -0
- package/lib/lilylet/abcDecoder.js +16 -7
- package/lib/lilylet/gmInstruments.d.ts +1 -0
- package/lib/lilylet/gmInstruments.js +295 -0
- package/lib/lilylet/highlight.d.ts +29 -0
- package/lib/lilylet/highlight.js +145 -0
- package/lib/lilylet/meiEncoder.js +126 -14
- package/lib/lilylet/staffLayout.d.ts +5 -0
- package/lib/lilylet/staffLayout.js +62 -0
- package/package.json +8 -2
- package/source/lilylet/abcDecoder.ts +14 -7
- package/source/lilylet/gmInstruments.ts +305 -0
- package/source/lilylet/highlight.ts +192 -0
- package/source/lilylet/meiEncoder.ts +135 -11
- package/source/lilylet/staffLayout.ts +76 -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,154 +0,0 @@
|
|
|
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
|
-
import { decode } from "../../source/lilylet/lilypondDecoder.js";
|
|
12
|
-
import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
|
|
13
|
-
import { parseCode } from "../../source/lilylet/parser.js";
|
|
14
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
15
|
-
let passed = 0;
|
|
16
|
-
let failed = 0;
|
|
17
|
-
function assert(condition, message) {
|
|
18
|
-
if (condition) {
|
|
19
|
-
console.log(` ✓ ${message}`);
|
|
20
|
-
passed++;
|
|
21
|
-
}
|
|
22
|
-
else {
|
|
23
|
-
console.error(` ✗ FAIL: ${message}`);
|
|
24
|
-
failed++;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
function findRests(doc) {
|
|
28
|
-
const rests = [];
|
|
29
|
-
for (const measure of doc.measures) {
|
|
30
|
-
for (const part of measure.parts) {
|
|
31
|
-
for (const voice of part.voices) {
|
|
32
|
-
for (const event of voice.events) {
|
|
33
|
-
if (event.type === 'rest')
|
|
34
|
-
rests.push(event);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return rests;
|
|
40
|
-
}
|
|
41
|
-
// ─── Warm-up parser (required before first decode call) ─────────────────────
|
|
42
|
-
{
|
|
43
|
-
const warn = console.warn, assert2 = console.assert;
|
|
44
|
-
console.warn = () => { };
|
|
45
|
-
console.assert = () => { };
|
|
46
|
-
try {
|
|
47
|
-
await decode('{ c }');
|
|
48
|
-
}
|
|
49
|
-
catch { /* ignore */ }
|
|
50
|
-
console.warn = warn;
|
|
51
|
-
console.assert = assert2;
|
|
52
|
-
}
|
|
53
|
-
// ─── Test 1: LilyPond decoder — R1 produces fullMeasure=true ────────────────
|
|
54
|
-
console.log('\nTest 1: lilypondDecoder R1 → fullMeasure=true');
|
|
55
|
-
const LY_BOILERPLATE = `
|
|
56
|
-
\\version "2.22.0"
|
|
57
|
-
\\language "english"
|
|
58
|
-
\\header { tagline = ##f }
|
|
59
|
-
#(set-global-staff-size 20)
|
|
60
|
-
\\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
|
|
61
|
-
\\layout { \\context { \\Score autoBeaming = ##f } }
|
|
62
|
-
`;
|
|
63
|
-
const LY_WITH_R1 = LY_BOILERPLATE + `
|
|
64
|
-
\\score {
|
|
65
|
-
\\new Staff = "1_1" <<
|
|
66
|
-
\\new Voice {
|
|
67
|
-
\\relative c' { \\time 4/4 \\clef treble c4 d e f } | % 1
|
|
68
|
-
}
|
|
69
|
-
\\new Voice {
|
|
70
|
-
\\relative c' { \\time 4/4 R1 } | % 1
|
|
71
|
-
}
|
|
72
|
-
>>
|
|
73
|
-
\\layout { }
|
|
74
|
-
}
|
|
75
|
-
`;
|
|
76
|
-
const LY_WITH_LOWERCASE_R = LY_BOILERPLATE + `
|
|
77
|
-
\\score {
|
|
78
|
-
\\new Staff = "1_1" <<
|
|
79
|
-
\\new Voice {
|
|
80
|
-
\\relative c' { \\time 4/4 \\clef treble r1 } | % 1
|
|
81
|
-
}
|
|
82
|
-
>>
|
|
83
|
-
\\layout { }
|
|
84
|
-
}
|
|
85
|
-
`;
|
|
86
|
-
await (async () => {
|
|
87
|
-
const docR = await decode(LY_WITH_R1);
|
|
88
|
-
const restsR = findRests(docR);
|
|
89
|
-
const fullMeasureRests = restsR.filter(r => r.fullMeasure);
|
|
90
|
-
assert(fullMeasureRests.length === 1, `R1 decoded: found ${fullMeasureRests.length} fullMeasure rest (expected 1)`);
|
|
91
|
-
if (fullMeasureRests.length > 0) {
|
|
92
|
-
assert(fullMeasureRests[0].duration.division === 1, `R1 duration.division === 1 (whole note)`);
|
|
93
|
-
assert(!fullMeasureRests[0].invisible, `R1 is not invisible`);
|
|
94
|
-
}
|
|
95
|
-
const docR_lc = await decode(LY_WITH_LOWERCASE_R);
|
|
96
|
-
const restsLc = findRests(docR_lc);
|
|
97
|
-
assert(restsLc.length === 1, `r1 decoded: found ${restsLc.length} rest (expected 1)`);
|
|
98
|
-
if (restsLc.length > 0) {
|
|
99
|
-
assert(!restsLc[0].fullMeasure, `r1 does NOT have fullMeasure flag`);
|
|
100
|
-
}
|
|
101
|
-
})();
|
|
102
|
-
// ─── Test 2: serializer — fullMeasure rest serializes as uppercase R ─────────
|
|
103
|
-
console.log('\nTest 2: serializer fullMeasure=true → uppercase R in .lyl');
|
|
104
|
-
await (async () => {
|
|
105
|
-
const doc = await decode(LY_WITH_R1);
|
|
106
|
-
const lyl = serializeLilyletDoc(doc);
|
|
107
|
-
// Should contain uppercase R1 (whole full-measure rest)
|
|
108
|
-
assert(/\bR1\b/.test(lyl), `lyl contains R1: ${lyl.includes('R1') ? 'yes' : 'NO — got: ' + lyl.slice(0, 200)}`);
|
|
109
|
-
// Should NOT contain standalone r1 (lowercase, a regular rest)
|
|
110
|
-
// Note: "r1" can appear as a sub-string of identifiers, so we check for word-boundary r1
|
|
111
|
-
const hasLowercaseR1 = /\br1\b/.test(lyl);
|
|
112
|
-
assert(!hasLowercaseR1, `lyl does not contain lowercase r1 where R1 is expected`);
|
|
113
|
-
})();
|
|
114
|
-
// ─── Test 3: round-trip R1 through .lyl parser ───────────────────────────────
|
|
115
|
-
console.log('\nTest 3: R1 .lyl round-trip — fullMeasure survives parseCode');
|
|
116
|
-
await (async () => {
|
|
117
|
-
const doc = await decode(LY_WITH_R1);
|
|
118
|
-
const lyl = serializeLilyletDoc(doc);
|
|
119
|
-
const docRT = parseCode(lyl);
|
|
120
|
-
const rests = findRests(docRT);
|
|
121
|
-
const fullMeasureRests = rests.filter(r => r.fullMeasure);
|
|
122
|
-
assert(fullMeasureRests.length >= 1, `Round-trip: found ${fullMeasureRests.length} fullMeasure rest (expected ≥1)`);
|
|
123
|
-
})();
|
|
124
|
-
// ─── Test 4: dotted variants (R2., R4.) also get fullMeasure=true ────────────
|
|
125
|
-
console.log('\nTest 4: dotted variants R2. R4. also get fullMeasure=true');
|
|
126
|
-
const LY_DOTTED = LY_BOILERPLATE + `
|
|
127
|
-
\\score {
|
|
128
|
-
\\new Staff = "1_1" <<
|
|
129
|
-
\\new Voice {
|
|
130
|
-
\\relative c' { \\time 3/4 \\clef treble c4 d e } | % 1
|
|
131
|
-
}
|
|
132
|
-
\\new Voice {
|
|
133
|
-
\\relative c' { \\time 3/4 R2. } | % 1
|
|
134
|
-
}
|
|
135
|
-
>>
|
|
136
|
-
\\layout { }
|
|
137
|
-
}
|
|
138
|
-
`;
|
|
139
|
-
await (async () => {
|
|
140
|
-
const doc = await decode(LY_DOTTED);
|
|
141
|
-
const rests = findRests(doc);
|
|
142
|
-
const fullMeasureRests = rests.filter(r => r.fullMeasure);
|
|
143
|
-
assert(fullMeasureRests.length === 1, `R2. decoded: found ${fullMeasureRests.length} fullMeasure rest (expected 1)`);
|
|
144
|
-
if (fullMeasureRests.length > 0) {
|
|
145
|
-
assert(fullMeasureRests[0].duration.division === 2, `R2. duration.division === 2`);
|
|
146
|
-
assert(fullMeasureRests[0].duration.dots === 1, `R2. duration.dots === 1`);
|
|
147
|
-
}
|
|
148
|
-
const lyl = serializeLilyletDoc(doc);
|
|
149
|
-
assert(/\bR2\.\s*\|/.test(lyl) || /\bR2\.\s*\\\\/.test(lyl), `lyl contains R2.: ${lyl.includes('R2.') ? 'yes' : 'NO'}`);
|
|
150
|
-
})();
|
|
151
|
-
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
152
|
-
console.log(`\n${'═'.repeat(40)}`);
|
|
153
|
-
console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
|
|
154
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests targeting issues raised in GPT code review of commits:
|
|
3
|
-
* - fix: serializer cross-staff context handling
|
|
4
|
-
* - fix: LilyPond decoder \change Staff support
|
|
5
|
-
*
|
|
6
|
-
* Usage: npx tsx tests/unit/gptReviewIssues.test.ts
|
|
7
|
-
*/
|
|
8
|
-
import { decode } from '../../source/lilylet/lilypondDecoder.js';
|
|
9
|
-
import { serializeLilyletDoc } from '../../source/lilylet/serializer.js';
|
|
10
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
11
|
-
let passed = 0;
|
|
12
|
-
let failed = 0;
|
|
13
|
-
function assert(condition, message) {
|
|
14
|
-
if (condition) {
|
|
15
|
-
console.log(` ✓ ${message}`);
|
|
16
|
-
passed++;
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
console.error(` ✗ FAIL: ${message}`);
|
|
20
|
-
failed++;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
const LY_BOILERPLATE = `
|
|
24
|
-
\\version "2.22.0"
|
|
25
|
-
\\language "english"
|
|
26
|
-
\\header { tagline = ##f }
|
|
27
|
-
#(set-global-staff-size 20)
|
|
28
|
-
\\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
|
|
29
|
-
\\layout { \\context { \\Score autoBeaming = ##f } }
|
|
30
|
-
`;
|
|
31
|
-
// Warm-up parser
|
|
32
|
-
{
|
|
33
|
-
const warn = console.warn, a2 = console.assert;
|
|
34
|
-
console.warn = () => { };
|
|
35
|
-
console.assert = () => { };
|
|
36
|
-
try {
|
|
37
|
-
await decode('{ c }');
|
|
38
|
-
}
|
|
39
|
-
catch { /* ignore */ }
|
|
40
|
-
console.warn = warn;
|
|
41
|
-
console.assert = a2;
|
|
42
|
-
}
|
|
43
|
-
function getVoiceEvents(doc) {
|
|
44
|
-
return doc.measures.flatMap(m => m.parts.flatMap(p => p.voices));
|
|
45
|
-
}
|
|
46
|
-
/** Assert that string A appears before string B in str. */
|
|
47
|
-
function assertOrder(str, a, b, label) {
|
|
48
|
-
const ia = str.indexOf(a);
|
|
49
|
-
const ib = str.indexOf(b);
|
|
50
|
-
assert(ia !== -1 && ib !== -1 && ia < ib, `${label}: "${a}" (pos ${ia}) appears before "${b}" (pos ${ib})`);
|
|
51
|
-
}
|
|
52
|
-
// ─── Issue 1a: Compound { staff, clef } on DIFFERENT staff ───────────────────
|
|
53
|
-
// Bug: serializer emits \staff "N" then `continue`, dropping the clef.
|
|
54
|
-
// Repro: build a LilyletDoc directly with a compound context event.
|
|
55
|
-
console.log('\nIssue 1a: Compound context { staff:2, clef:"bass" } — serializer must emit both');
|
|
56
|
-
{
|
|
57
|
-
const doc = {
|
|
58
|
-
measures: [{
|
|
59
|
-
parts: [{
|
|
60
|
-
voices: [{
|
|
61
|
-
staff: 1,
|
|
62
|
-
events: [
|
|
63
|
-
{ type: 'context', staff: 2, clef: 'bass' },
|
|
64
|
-
{ type: 'note', pitches: [{ phonet: 'g', octave: -1 }], duration: { division: 4, dots: 0 } },
|
|
65
|
-
]
|
|
66
|
-
}]
|
|
67
|
-
}]
|
|
68
|
-
}]
|
|
69
|
-
};
|
|
70
|
-
const lyl = serializeLilyletDoc(doc);
|
|
71
|
-
assert(lyl.includes('\\staff "2"'), `Compound event: emits \\staff "2" — got: ${lyl}`);
|
|
72
|
-
assert(lyl.includes('\\clef "bass"'), `Compound event: emits \\clef "bass" — got: ${lyl}`);
|
|
73
|
-
assertOrder(lyl, '\\staff "2"', '\\clef "bass"', 'Compound diff-staff');
|
|
74
|
-
}
|
|
75
|
-
// ─── Issue 1b: Compound { staff, clef } on SAME staff ────────────────────────
|
|
76
|
-
// Bug: `if (ctx.staff) continue` drops clef when staff unchanged.
|
|
77
|
-
console.log('\nIssue 1b: Compound context { staff:1, clef:"bass" } same staff — clef must not be dropped');
|
|
78
|
-
{
|
|
79
|
-
const doc = {
|
|
80
|
-
measures: [{
|
|
81
|
-
parts: [{
|
|
82
|
-
voices: [{
|
|
83
|
-
staff: 1,
|
|
84
|
-
events: [
|
|
85
|
-
{ type: 'context', staff: 1, clef: 'bass' },
|
|
86
|
-
{ type: 'note', pitches: [{ phonet: 'c', octave: 0 }], duration: { division: 4, dots: 0 } },
|
|
87
|
-
]
|
|
88
|
-
}]
|
|
89
|
-
}]
|
|
90
|
-
}]
|
|
91
|
-
};
|
|
92
|
-
const lyl = serializeLilyletDoc(doc);
|
|
93
|
-
// staff:1 is same as voice.staff so no \staff switch needed, but clef MUST appear
|
|
94
|
-
assert(lyl.includes('\\clef "bass"'), `Same-staff compound event: emits \\clef "bass" — got: ${lyl}`);
|
|
95
|
-
}
|
|
96
|
-
// ─── Issue 2: Rest ordering — \staff "2" must come BEFORE rest in output ─────
|
|
97
|
-
console.log('\nIssue 2: \\staff "2" appears before rest, \\staff "1" before return rest (ordering)');
|
|
98
|
-
await (async () => {
|
|
99
|
-
const LY = LY_BOILERPLATE + `
|
|
100
|
-
\\score {
|
|
101
|
-
\\new Staff = "1_1" <<
|
|
102
|
-
\\new Voice {
|
|
103
|
-
\\relative c' { \\change Staff = "2" r2 \\change Staff = "1" r2 }
|
|
104
|
-
}
|
|
105
|
-
>>
|
|
106
|
-
\\layout { }
|
|
107
|
-
}
|
|
108
|
-
`;
|
|
109
|
-
const doc = await decode(LY);
|
|
110
|
-
const lyl = serializeLilyletDoc(doc);
|
|
111
|
-
// Match the full pattern: \staff "2" r... \staff "1" r...
|
|
112
|
-
// (duration may be elided on second rest)
|
|
113
|
-
assert(/\\staff "2"\s+r\d*/.test(lyl), `\\staff "2" immediately before first rest`);
|
|
114
|
-
assert(/\\staff "1"\s+r\d*/.test(lyl), `\\staff "1" immediately before second rest`);
|
|
115
|
-
})();
|
|
116
|
-
// ─── Issue 3: Non-numeric \change Staff = "RH" — notes still decode correctly ─
|
|
117
|
-
console.log('\nIssue 3: \\change Staff = "RH" — notes after ignored change still decode');
|
|
118
|
-
await (async () => {
|
|
119
|
-
const LY = LY_BOILERPLATE + `
|
|
120
|
-
\\score {
|
|
121
|
-
\\new Staff = "1_1" <<
|
|
122
|
-
\\new Voice {
|
|
123
|
-
\\relative c' { \\change Staff = "RH" g2 g2 }
|
|
124
|
-
}
|
|
125
|
-
>>
|
|
126
|
-
\\layout { }
|
|
127
|
-
}
|
|
128
|
-
`;
|
|
129
|
-
const doc = await decode(LY);
|
|
130
|
-
const voices = getVoiceEvents(doc);
|
|
131
|
-
const notes = voices.flatMap(v => v.events.filter(e => e.type === 'note'));
|
|
132
|
-
assert(notes.length === 2, `Notes after ignored \\change Staff still decode — got ${notes.length}`);
|
|
133
|
-
const staffCtx = voices.flatMap(v => v.events.filter(e => e.type === 'context' && e.staff != null));
|
|
134
|
-
assert(staffCtx.length === 0, `Non-numeric name produces 0 staff context events (got ${staffCtx.length})`);
|
|
135
|
-
})();
|
|
136
|
-
// ─── Issue 4: Adversarial digit-stripping — "1_2", "foo2bar", "1 2" ──────────
|
|
137
|
-
console.log('\nIssue 4: Adversarial staff names via digit-stripping regex');
|
|
138
|
-
await (async () => {
|
|
139
|
-
// "1_2" is a realistic case (GrandStaff-style staff names in generated .ly files)
|
|
140
|
-
const LY_1_2 = LY_BOILERPLATE + `
|
|
141
|
-
\\score {
|
|
142
|
-
\\new Staff = "1_1" <<
|
|
143
|
-
\\new Voice {
|
|
144
|
-
\\relative c' { \\change Staff = "1_2" g2 g2 }
|
|
145
|
-
}
|
|
146
|
-
>>
|
|
147
|
-
\\layout { }
|
|
148
|
-
}
|
|
149
|
-
`;
|
|
150
|
-
const doc = await decode(LY_1_2);
|
|
151
|
-
const voices = getVoiceEvents(doc);
|
|
152
|
-
const staffCtx = voices.flatMap(v => v.events.filter(e => e.type === 'context' && e.staff != null));
|
|
153
|
-
const staffNums = staffCtx.map((e) => e.staff);
|
|
154
|
-
// "1_2".replace(/[^0-9]/g) → "12" → parseInt → 12
|
|
155
|
-
// This is the digit-stripping coercion bug.
|
|
156
|
-
// Document actual behavior: if 12 appears, that is the bug.
|
|
157
|
-
if (staffNums.includes(12)) {
|
|
158
|
-
assert(false, `BUG: "1_2" was coerced to staff 12 by digit-stripping (got ${staffNums})`);
|
|
159
|
-
}
|
|
160
|
-
else if (staffNums.length === 0) {
|
|
161
|
-
// Acceptable if treated as unparseable and dropped
|
|
162
|
-
assert(true, `"1_2" treated as non-numeric, silently dropped (staff events: 0)`);
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
// Any other result should be explicit
|
|
166
|
-
assert(false, `Unexpected staff numbers from "1_2": ${staffNums}`);
|
|
167
|
-
}
|
|
168
|
-
})();
|
|
169
|
-
// ─── Issue 5: Multiple successive switches — exact sequence ──────────────────
|
|
170
|
-
console.log('\nIssue 5: Multiple successive staff switches — exact event sequence');
|
|
171
|
-
await (async () => {
|
|
172
|
-
const LY = LY_BOILERPLATE + `
|
|
173
|
-
\\score {
|
|
174
|
-
\\new Staff = "1_1" <<
|
|
175
|
-
\\new Voice {
|
|
176
|
-
\\relative c' {
|
|
177
|
-
\\change Staff = "2" g4
|
|
178
|
-
\\change Staff = "1" c4
|
|
179
|
-
\\change Staff = "2" g4
|
|
180
|
-
\\change Staff = "1" c4
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
>>
|
|
184
|
-
\\layout { }
|
|
185
|
-
}
|
|
186
|
-
`;
|
|
187
|
-
const doc = await decode(LY);
|
|
188
|
-
// Verify exact decoded event sequence
|
|
189
|
-
const voices = getVoiceEvents(doc);
|
|
190
|
-
const seq = [];
|
|
191
|
-
for (const v of voices) {
|
|
192
|
-
let activeStaff = v.staff;
|
|
193
|
-
for (const e of v.events) {
|
|
194
|
-
if (e.type === 'context' && e.staff != null) {
|
|
195
|
-
activeStaff = e.staff;
|
|
196
|
-
seq.push(`staff:${activeStaff}`);
|
|
197
|
-
}
|
|
198
|
-
if (e.type === 'note')
|
|
199
|
-
seq.push(`note`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
const expected = ['staff:2', 'note', 'staff:1', 'note', 'staff:2', 'note', 'staff:1', 'note'];
|
|
203
|
-
assert(JSON.stringify(seq) === JSON.stringify(expected), `Exact sequence: [${seq.join(',')}] === [${expected.join(',')}]`);
|
|
204
|
-
// Verify serialized output has correct ordering
|
|
205
|
-
const lyl = serializeLilyletDoc(doc);
|
|
206
|
-
// Remove whitespace/newlines for easier regex
|
|
207
|
-
const flat = lyl.replace(/\s+/g, ' ');
|
|
208
|
-
// Should see: staff "2" ... g4 ... staff "1" ... c4 ... staff "2" ... g4 ... staff "1" ... c4
|
|
209
|
-
// Match patterns: \staff "2" g... and \staff "1" c... (duration may be elided)
|
|
210
|
-
assert(/\\staff "2"\s+g\d*/.test(flat), `\\staff "2" immediately before g note`);
|
|
211
|
-
assert(/\\staff "1"\s+c\d*/.test(flat), `\\staff "1" immediately before c note`);
|
|
212
|
-
})();
|
|
213
|
-
// ─── Issue 5b: Same-staff switch twice in a row ───────────────────────────────
|
|
214
|
-
console.log('\nIssue 5b: Same-staff switch twice in a row — no duplicate \\staff emission');
|
|
215
|
-
await (async () => {
|
|
216
|
-
const LY = LY_BOILERPLATE + `
|
|
217
|
-
\\score {
|
|
218
|
-
\\new Staff = "1_1" <<
|
|
219
|
-
\\new Voice {
|
|
220
|
-
\\relative c' {
|
|
221
|
-
\\change Staff = "2" g4
|
|
222
|
-
\\change Staff = "2" g4
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
>>
|
|
226
|
-
\\layout { }
|
|
227
|
-
}
|
|
228
|
-
`;
|
|
229
|
-
const doc = await decode(LY);
|
|
230
|
-
const lyl = serializeLilyletDoc(doc);
|
|
231
|
-
const count = (lyl.match(/\\staff "2"/g) || []).length;
|
|
232
|
-
// Ideally emitted only once (serializer dedupes same-staff switch)
|
|
233
|
-
assert(count >= 1, `At least one \\staff "2" emitted (got ${count})`);
|
|
234
|
-
// Document: currently emits it twice (one per context event) or once (deduplicated)
|
|
235
|
-
console.log(` (info) \\staff "2" count: ${count} — serializer ${count === 1 ? 'deduplicates' : 'does NOT deduplicate'} same-staff repeat`);
|
|
236
|
-
})();
|
|
237
|
-
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
238
|
-
console.log(`\n${'═'.repeat(50)}`);
|
|
239
|
-
console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
|
|
240
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit test: \parallelMusic decoding in the LilyPond decoder.
|
|
3
|
-
*
|
|
4
|
-
* \parallelMusic distributes measures round-robin across named variables:
|
|
5
|
-
* \parallelMusic #'(voiceA voiceB) { m1a | m1b | m2a | m2b | }
|
|
6
|
-
* → voiceA = [m1a, m2a], voiceB = [m1b, m2b]
|
|
7
|
-
* Variables are then referenced in a \score block.
|
|
8
|
-
*
|
|
9
|
-
* Reference: https://lilypond.org/doc/v2.23/Documentation/notation/multiple-voices#writing-music-in-parallel
|
|
10
|
-
*
|
|
11
|
-
* Usage: npx tsx tests/unit/parallelMusicDecoder.test.ts
|
|
12
|
-
*/
|
|
13
|
-
export {};
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit test: \parallelMusic decoding in the LilyPond decoder.
|
|
3
|
-
*
|
|
4
|
-
* \parallelMusic distributes measures round-robin across named variables:
|
|
5
|
-
* \parallelMusic #'(voiceA voiceB) { m1a | m1b | m2a | m2b | }
|
|
6
|
-
* → voiceA = [m1a, m2a], voiceB = [m1b, m2b]
|
|
7
|
-
* Variables are then referenced in a \score block.
|
|
8
|
-
*
|
|
9
|
-
* Reference: https://lilypond.org/doc/v2.23/Documentation/notation/multiple-voices#writing-music-in-parallel
|
|
10
|
-
*
|
|
11
|
-
* Usage: npx tsx tests/unit/parallelMusicDecoder.test.ts
|
|
12
|
-
*/
|
|
13
|
-
import { decode } from '../../source/lilylet/lilypondDecoder.js';
|
|
14
|
-
import { serializeLilyletDoc } from '../../source/lilylet/serializer.js';
|
|
15
|
-
import { parseCode } from '../../source/lilylet/parser.js';
|
|
16
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
17
|
-
let passed = 0;
|
|
18
|
-
let failed = 0;
|
|
19
|
-
function assert(condition, message) {
|
|
20
|
-
if (condition) {
|
|
21
|
-
console.log(` ✓ ${message}`);
|
|
22
|
-
passed++;
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
console.error(` ✗ FAIL: ${message}`);
|
|
26
|
-
failed++;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
// Warm-up parser
|
|
30
|
-
{
|
|
31
|
-
const w = console.warn, a = console.assert;
|
|
32
|
-
console.warn = () => { };
|
|
33
|
-
console.assert = () => { };
|
|
34
|
-
try {
|
|
35
|
-
await decode('{ c }');
|
|
36
|
-
}
|
|
37
|
-
catch { }
|
|
38
|
-
console.warn = w;
|
|
39
|
-
console.assert = a;
|
|
40
|
-
}
|
|
41
|
-
function getNotes(doc) {
|
|
42
|
-
return doc.measures.flatMap(m => m.parts.flatMap(p => p.voices.flatMap(v => v.events
|
|
43
|
-
.filter(e => e.type === 'note')
|
|
44
|
-
.map(e => e.pitches[0].phonet))));
|
|
45
|
-
}
|
|
46
|
-
function getMeasureCount(doc) {
|
|
47
|
-
return doc.measures.length;
|
|
48
|
-
}
|
|
49
|
-
// ─── Test input ───────────────────────────────────────────────────────────────
|
|
50
|
-
//
|
|
51
|
-
// Original \parallelMusic syntax with octave marks, as used in real scores.
|
|
52
|
-
// Expected absolute pitches verified by running through the lotus decoder:
|
|
53
|
-
//
|
|
54
|
-
// voiceA \relative c'':
|
|
55
|
-
// m1: c'4 d e f → c(2) d(2) e(2) f(2) (C6=oct2, since c' in \relative c'')
|
|
56
|
-
// m2: a'4 b c d → a(3) b(3) c(4) d(4) (continues from f(2), a'→oct3, wraps)
|
|
57
|
-
//
|
|
58
|
-
// voiceB \relative c':
|
|
59
|
-
// m1: g,2 g → g(-2) g(-2) (G, relative c' with , → very low)
|
|
60
|
-
// m2: e,2 e → e(-3) e(-3) (continues from g(-2))
|
|
61
|
-
//
|
|
62
|
-
// \parallelMusic interleaves: voiceA-m1 | voiceB-m1 | voiceA-m2 | voiceB-m2
|
|
63
|
-
const LY_PARALLEL = `
|
|
64
|
-
\\version "2.22.0"
|
|
65
|
-
\\language "english"
|
|
66
|
-
\\header { tagline = ##f }
|
|
67
|
-
|
|
68
|
-
\\parallelMusic #'(voiceA voiceB)
|
|
69
|
-
{
|
|
70
|
-
c'4 d e f |
|
|
71
|
-
g,2 g |
|
|
72
|
-
a'4 b c d |
|
|
73
|
-
e,2 e |
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
\\score {
|
|
77
|
-
\\new StaffGroup <<
|
|
78
|
-
\\new Staff {
|
|
79
|
-
\\new Voice = "va" {
|
|
80
|
-
\\relative c'' \\voiceA
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
\\new Staff {
|
|
84
|
-
\\clef "bass"
|
|
85
|
-
\\new Voice = "vb" {
|
|
86
|
-
\\relative c' \\voiceB
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
>>
|
|
90
|
-
\\layout {}
|
|
91
|
-
}
|
|
92
|
-
`;
|
|
93
|
-
// ─── Test 1: decoder produces the correct number of measures ─────────────────
|
|
94
|
-
console.log('\nTest 1: \\parallelMusic — correct measure count decoded');
|
|
95
|
-
await (async () => {
|
|
96
|
-
let doc;
|
|
97
|
-
let threw = false;
|
|
98
|
-
try {
|
|
99
|
-
doc = await decode(LY_PARALLEL);
|
|
100
|
-
}
|
|
101
|
-
catch (e) {
|
|
102
|
-
threw = true;
|
|
103
|
-
console.log(` ! Decoder threw: ${e.message}`);
|
|
104
|
-
}
|
|
105
|
-
assert(!threw, 'Decoder does not throw on \\parallelMusic input');
|
|
106
|
-
if (!doc)
|
|
107
|
-
return;
|
|
108
|
-
const measures = getMeasureCount(doc);
|
|
109
|
-
assert(measures === 2, `Decoded 2 measures (got ${measures})`);
|
|
110
|
-
})();
|
|
111
|
-
// ─── Test 2: voiceA notes are present (c d e f / a b c d) ────────────────────
|
|
112
|
-
console.log('\nTest 2: voiceA notes decoded correctly');
|
|
113
|
-
await (async () => {
|
|
114
|
-
const doc = await decode(LY_PARALLEL);
|
|
115
|
-
const allNotes = getNotes(doc);
|
|
116
|
-
// voiceA should contain: c d e f (m1) and a b c d (m2)
|
|
117
|
-
assert(allNotes.includes('c'), `Note 'c' present`);
|
|
118
|
-
assert(allNotes.includes('d'), `Note 'd' present`);
|
|
119
|
-
assert(allNotes.includes('e'), `Note 'e' present`);
|
|
120
|
-
assert(allNotes.includes('f'), `Note 'f' present`);
|
|
121
|
-
// voiceB should contain: g (m1) and e (m2)
|
|
122
|
-
assert(allNotes.includes('g'), `Note 'g' present (voiceB)`);
|
|
123
|
-
const totalNotes = allNotes.length;
|
|
124
|
-
assert(totalNotes >= 10, `Total note count ≥ 10 (got ${totalNotes})`);
|
|
125
|
-
})();
|
|
126
|
-
// ─── Test 3: voiceA and voiceB land on separate staves/voices ────────────────
|
|
127
|
-
console.log('\nTest 3: voiceA and voiceB produce distinct voice entries');
|
|
128
|
-
await (async () => {
|
|
129
|
-
const doc = await decode(LY_PARALLEL);
|
|
130
|
-
// Count total voice entries across all measures
|
|
131
|
-
const voiceEntries = doc.measures.flatMap(m => m.parts.flatMap(p => p.voices));
|
|
132
|
-
assert(voiceEntries.length >= 2, `At least 2 voice entries across all measures (got ${voiceEntries.length})`);
|
|
133
|
-
// Each measure should have at least one voice with notes
|
|
134
|
-
for (let mi = 0; mi < doc.measures.length; mi++) {
|
|
135
|
-
const notes = doc.measures[mi].parts.flatMap(p => p.voices.flatMap(v => v.events.filter(e => e.type === 'note')));
|
|
136
|
-
assert(notes.length > 0, `Measure ${mi + 1} has note events (got ${notes.length})`);
|
|
137
|
-
}
|
|
138
|
-
})();
|
|
139
|
-
// ─── Test 4: absolute pitch correctness — decoded octaves match LilyPond ──────
|
|
140
|
-
//
|
|
141
|
-
// Each pitch is represented as {phonet, octave} absolute values computed by lotus.
|
|
142
|
-
// Expected values were verified by running the decoder (not manually guessed):
|
|
143
|
-
// voiceA m1: c(2) d(2) e(2) f(2) — c' in \relative c'' = C6
|
|
144
|
-
// voiceA m2: a(3) b(3) c(4) d(4) — continues from f(2), a' pushes to oct3+
|
|
145
|
-
// voiceB m1: g(-2) g(-2) — g, in \relative c' = very low G
|
|
146
|
-
// voiceB m2: e(-3) e(-3) — continues from g(-2)
|
|
147
|
-
console.log('\nTest 4: absolute pitch correctness (phonet + octave match LilyPond semantics)');
|
|
148
|
-
await (async () => {
|
|
149
|
-
const doc = await decode(LY_PARALLEL);
|
|
150
|
-
// Collect all note pitches grouped by measure and voice order
|
|
151
|
-
const byMeasure = [];
|
|
152
|
-
for (let mi = 0; mi < doc.measures.length; mi++) {
|
|
153
|
-
byMeasure[mi] = doc.measures[mi].parts
|
|
154
|
-
.flatMap(p => p.voices)
|
|
155
|
-
.map(v => v.events
|
|
156
|
-
.filter(e => e.type === 'note')
|
|
157
|
-
.map(e => ({ phonet: e.pitches[0].phonet, octave: e.pitches[0].octave })))
|
|
158
|
-
.filter(a => a.length > 0);
|
|
159
|
-
}
|
|
160
|
-
const fmt = (ps) => ps.map(p => `${p.phonet}(${p.octave})`).join(' ');
|
|
161
|
-
// voiceA m1: c(2) d(2) e(2) f(2)
|
|
162
|
-
const vaM1 = byMeasure[0]?.find(v => v.some(p => p.phonet === 'c' && p.octave === 2));
|
|
163
|
-
assert(!!vaM1, `voiceA m1 found at expected octave 2`);
|
|
164
|
-
if (vaM1) {
|
|
165
|
-
const expected = [{ phonet: 'c', octave: 2 }, { phonet: 'd', octave: 2 }, { phonet: 'e', octave: 2 }, { phonet: 'f', octave: 2 }];
|
|
166
|
-
assert(JSON.stringify(vaM1) === JSON.stringify(expected), `voiceA m1 pitches: [${fmt(vaM1)}] === [${fmt(expected)}]`);
|
|
167
|
-
}
|
|
168
|
-
// voiceA m2: a(3) b(3) c(4) d(4)
|
|
169
|
-
const vaM2 = byMeasure[1]?.find(v => v.some(p => p.phonet === 'a' && p.octave === 3));
|
|
170
|
-
assert(!!vaM2, `voiceA m2 found at expected octave 3+`);
|
|
171
|
-
if (vaM2) {
|
|
172
|
-
const expected = [{ phonet: 'a', octave: 3 }, { phonet: 'b', octave: 3 }, { phonet: 'c', octave: 4 }, { phonet: 'd', octave: 4 }];
|
|
173
|
-
assert(JSON.stringify(vaM2) === JSON.stringify(expected), `voiceA m2 pitches: [${fmt(vaM2)}] === [${fmt(expected)}]`);
|
|
174
|
-
}
|
|
175
|
-
// voiceB m1: g(-2) g(-2)
|
|
176
|
-
const vbM1 = byMeasure[0]?.find(v => v.some(p => p.phonet === 'g' && p.octave === -2));
|
|
177
|
-
assert(!!vbM1, `voiceB m1 found at expected octave -2`);
|
|
178
|
-
if (vbM1) {
|
|
179
|
-
const expected = [{ phonet: 'g', octave: -2 }, { phonet: 'g', octave: -2 }];
|
|
180
|
-
assert(JSON.stringify(vbM1) === JSON.stringify(expected), `voiceB m1 pitches: [${fmt(vbM1)}] === [${fmt(expected)}]`);
|
|
181
|
-
}
|
|
182
|
-
// voiceB m2: e(-3) e(-3)
|
|
183
|
-
const vbM2 = byMeasure[1]?.find(v => v.some(p => p.phonet === 'e' && p.octave === -3));
|
|
184
|
-
assert(!!vbM2, `voiceB m2 found at expected octave -3`);
|
|
185
|
-
if (vbM2) {
|
|
186
|
-
const expected = [{ phonet: 'e', octave: -3 }, { phonet: 'e', octave: -3 }];
|
|
187
|
-
assert(JSON.stringify(vbM2) === JSON.stringify(expected), `voiceB m2 pitches: [${fmt(vbM2)}] === [${fmt(expected)}]`);
|
|
188
|
-
}
|
|
189
|
-
})();
|
|
190
|
-
// ─── Test 5: serializer→parser round-trip preserves absolute pitches ──────────
|
|
191
|
-
// The serializer converts absolute pitches back to \relative lyl syntax.
|
|
192
|
-
// If there is per-measure relative-mode drift, the parsed-back octaves will
|
|
193
|
-
// differ from the decoded-doc octaves.
|
|
194
|
-
console.log('\nTest 5: serialized .lyl round-trip preserves absolute pitches (detect relative-mode drift)');
|
|
195
|
-
await (async () => {
|
|
196
|
-
const doc = await decode(LY_PARALLEL);
|
|
197
|
-
const lyl = serializeLilyletDoc(doc);
|
|
198
|
-
console.log(' lyl output:\n' + lyl.split('\n').map(l => ' ' + l).join('\n'));
|
|
199
|
-
let docRT;
|
|
200
|
-
try {
|
|
201
|
-
docRT = parseCode(lyl);
|
|
202
|
-
}
|
|
203
|
-
catch (e) {
|
|
204
|
-
assert(false, `parseCode threw: ${e.message}`);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
// Extract flat list of {phonet, octave} from all note events
|
|
208
|
-
const getPitches = (d) => d.measures.flatMap(m => m.parts.flatMap(p => p.voices.flatMap(v => v.events
|
|
209
|
-
.filter(e => e.type === 'note')
|
|
210
|
-
.map(e => {
|
|
211
|
-
const n = e;
|
|
212
|
-
return { phonet: n.pitches[0].phonet, octave: n.pitches[0].octave };
|
|
213
|
-
}))));
|
|
214
|
-
const origPitches = getPitches(doc);
|
|
215
|
-
const rtPitches = getPitches(docRT);
|
|
216
|
-
console.log(' orig pitches:', origPitches.map(p => p.phonet + p.octave).join(' '));
|
|
217
|
-
console.log(' rt pitches:', rtPitches.map(p => p.phonet + p.octave).join(' '));
|
|
218
|
-
assert(origPitches.length === rtPitches.length, `Same note count: ${origPitches.length} === ${rtPitches.length}`);
|
|
219
|
-
let allMatch = true;
|
|
220
|
-
for (let i = 0; i < origPitches.length; i++) {
|
|
221
|
-
const o = origPitches[i], r = rtPitches[i];
|
|
222
|
-
if (o.phonet !== r.phonet || o.octave !== r.octave) {
|
|
223
|
-
assert(false, `Pitch ${i}: ${o.phonet}(${o.octave}) → round-trip gave ${r.phonet}(${r.octave}) — relative-mode drift`);
|
|
224
|
-
allMatch = false;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
if (allMatch) {
|
|
228
|
-
assert(true, `All ${origPitches.length} pitches preserved through serializer round-trip`);
|
|
229
|
-
}
|
|
230
|
-
})();
|
|
231
|
-
// ─── Test 6: serialization produces valid .lyl ────────────────────────────────
|
|
232
|
-
console.log('\nTest 6: serialized .lyl parses back without error');
|
|
233
|
-
await (async () => {
|
|
234
|
-
const doc = await decode(LY_PARALLEL);
|
|
235
|
-
const lyl = serializeLilyletDoc(doc);
|
|
236
|
-
console.log(' lyl output:\n' + lyl.split('\n').map(l => ' ' + l).join('\n'));
|
|
237
|
-
let threw = false;
|
|
238
|
-
let docRT;
|
|
239
|
-
try {
|
|
240
|
-
docRT = parseCode(lyl);
|
|
241
|
-
}
|
|
242
|
-
catch (e) {
|
|
243
|
-
threw = true;
|
|
244
|
-
console.log(` ! parseCode threw: ${e.message}`);
|
|
245
|
-
}
|
|
246
|
-
assert(!threw, 'Serialized .lyl parses back without error');
|
|
247
|
-
if (!docRT)
|
|
248
|
-
return;
|
|
249
|
-
const rtMeasures = getMeasureCount(docRT);
|
|
250
|
-
assert(rtMeasures === getMeasureCount(doc), `Round-trip measure count matches (${rtMeasures} === ${getMeasureCount(doc)})`);
|
|
251
|
-
const rtNotes = getNotes(docRT);
|
|
252
|
-
const origNotes = getNotes(doc);
|
|
253
|
-
assert(rtNotes.length === origNotes.length, `Round-trip note count matches (${rtNotes.length} === ${origNotes.length})`);
|
|
254
|
-
})();
|
|
255
|
-
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
256
|
-
console.log(`\n${'═'.repeat(50)}`);
|
|
257
|
-
if (failed > 0) {
|
|
258
|
-
console.log(`⚠️ ${failed} FAILED — \\parallelMusic not fully supported by decoder`);
|
|
259
|
-
}
|
|
260
|
-
console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
|
|
261
|
-
process.exit(failed > 0 ? 1 : 0);
|