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:
c0dev0id
2026-06-27 16:14:56 +02:00
parent 3cd66f1e10
commit b887341df4
5 changed files with 233 additions and 66 deletions

View File

@@ -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);