Add motif/stem_note display and offset note hierarchy
- parseAstLog: abort on depth jump >1 or missing slot (depth>0) - buildStemNote: full clause tree (notes/pauses/stacks), editable fields - buildClause: replaces buildCluster; filters nested clause.chain silently - buildMotif: builds from voice.motif; sets isStatic - buildOffset: uses buildStemNote; drops old clusters/chains arrays - buildVoice: motifs are now objects, not label strings - PaneSubObjects: static motifs before offsets; motif/offset→stem_note drill-down - PaneFO: motif + stem_note FO cases; unknownProps display helper - PaneCP: motif + stem_note shortView cases - test-parser.mjs: 21 new synthetic tests for motif/clause/stack/guards
This commit is contained in:
@@ -429,6 +429,101 @@ ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ??
|
||||
const nullInfoPatched = patchScore(META_SCORE, [], [], null);
|
||||
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
||||
|
||||
// ── Motif parsing ─────────────────────────────────────────────────────────
|
||||
section('Motif parsing — dynamic motif');
|
||||
const DYN_MOTIF = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='oct'
|
||||
03 motif.stem_note pitch=0
|
||||
04 chain.clause 0 repeat=1
|
||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
||||
05 clause.note letter='o' shift=12 netlength=1 length=1
|
||||
`;
|
||||
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
|
||||
const dynVoice = dynBar.voices['pi'];
|
||||
ok('motif is object', typeof dynVoice.motifs[0] === 'object');
|
||||
ok('motif label', dynVoice.motifs[0].label === 'oct');
|
||||
ok('motif pitch=0', dynVoice.motifs[0].stemNotes[0].pitch === 0);
|
||||
ok('dynamic motif isStatic=false', dynVoice.motifs[0].isStatic === false);
|
||||
ok('dynamic motif has 1 clause', dynVoice.motifs[0].stemNotes[0].clauses.length === 1);
|
||||
ok('clause has 2 notes', dynVoice.motifs[0].stemNotes[0].clauses[0].notes.length === 2);
|
||||
|
||||
section('Motif parsing — static motif');
|
||||
const STAT_MOTIF = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='cadence'
|
||||
03 motif.stem_note pitch='C4'
|
||||
04 chain.clause 0
|
||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
||||
`;
|
||||
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
|
||||
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
|
||||
ok('static motif pitch string', statVoice.motifs[0].stemNotes[0].pitch === 'C4');
|
||||
|
||||
section('Motif parsing — pause and stack in clause');
|
||||
const PS_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 offset.stem_note pitch='C4'
|
||||
04 chain.clause 0
|
||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
||||
05 clause.pause length=1
|
||||
04 chain.clause 1
|
||||
05 clause.stack length=2 netlength=2
|
||||
06 stack.note letter='o' shift=0
|
||||
06 stack.note letter='e' shift=0
|
||||
`;
|
||||
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
|
||||
const psSn = psVoice.offsets[0].stemNotes[0];
|
||||
ok('two clauses', psSn.clauses.length === 2);
|
||||
ok('first clause has 1 note + 1 pause', psSn.clauses[0].notes.length === 1 && psSn.clauses[0].pauses.length === 1);
|
||||
ok('second clause has 1 stack', psSn.clauses[1].stacks.length === 1);
|
||||
ok('stack has 2 notes', psSn.clauses[1].stacks[0].notes.length === 2);
|
||||
|
||||
section('Motif parsing — nested chain ignored gracefully');
|
||||
const NC_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 offset.stem_note pitch='C4'
|
||||
04 chain.clause 0
|
||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
||||
05 clause.chain length=2
|
||||
06 chain.clause 0
|
||||
07 clause.stack length=1 netlength=1
|
||||
08 stack.note letter='o' shift=0
|
||||
`;
|
||||
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||
ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1);
|
||||
ok('nested chain: only direct note counted', ncSn.clauses[0].notes.length === 1);
|
||||
ok('nested chain: no stacks leak from nested chain', ncSn.clauses[0].stacks.length === 0);
|
||||
|
||||
section('Depth-jump guard (abort)');
|
||||
const DJ_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='oct'
|
||||
03 motif.stem_note pitch='C4'
|
||||
06 chain.clause 0
|
||||
`;
|
||||
let djThrew = false;
|
||||
try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; }
|
||||
ok('depth jump throws', djThrew);
|
||||
|
||||
section('No-slot guard (abort)');
|
||||
const NS_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 voice
|
||||
`;
|
||||
let nsThrew = false;
|
||||
try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; }
|
||||
ok('missing slot throws', nsThrew);
|
||||
|
||||
section('Full fixture integration');
|
||||
ok('fixture parses without error (checked above)', raw && raw.slot === 'root');
|
||||
ok('fixture has 432 bars', model.bars.length === 432);
|
||||
const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter(v => v.motifs.length > 0);
|
||||
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m)));
|
||||
const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0);
|
||||
ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user