phases 4-6: article system, stem note fields, edit-state cleanup

Phase 4 — remove edit-state flags from domain objects:
- ast-parser.js: drop isDirty from buildInstrument, buildBar, buildStemNote
- store.js: add resetEditState()
- PaneCP.js: use resetEditState() on import; drop flags arg from patchScore
- PaneFO.js: isDirty -> _modified for linked instruments; no per-node dirty tracking
- PaneSubObjects.js: _spliceNode helper; deletion via splice; _isNew on new bars
- exporter.js: on-demand traversal; absence from model means deleted

Phase 5 — article parser and exporter:
- ast-parser.js: _buildArticleEntry replaces _mergeArticleEntry; stage.article and
  voice.article dispatch; article properties carry scope and overwritten flag
- exporter.js: _buildArticlesBlock emits -important: list for overwrites scope

Phase 6 — stem note dead fields:
- ast-parser.js: parse weight and articulatory on stem notes; flatten
  article.definite properties onto stem note model sibling to pitch/chain
This commit is contained in:
c0dev0id
2026-07-11 12:07:05 +02:00
parent e2b7af77d6
commit 7ffa0a9d2d
7 changed files with 166 additions and 129 deletions

View File

@@ -55,9 +55,10 @@ ok('articles[0].properties[]', Array.isArray(model.articles[0].properties)
const fProp = model.articles[0].properties.find(p => p.name === 'add_stress');
ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults');
// Merge across subtypes: same label, different subtypes → one entry, multi-scope props
const MERGE_FIXTURE = `01 articles.defaults 'g' add_stress=2
01 articles.overwrites 'g' pitch_bend=0.05
// Article slot structure: stage.article with article.defaults / article.overwrites children
const MERGE_FIXTURE = `01 stage.article 'g'
02 article.defaults 'add_stress' constant=2
02 article.overwrites 'pitch_bend'
`;
const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE);
const mergeModel = buildModel(mergeRoot);
@@ -67,7 +68,33 @@ ok('merge: 2 properties', mergeModel.articles[0].properties.length
const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults');
const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites');
ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2);
ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === 0.05);
ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === null);
section('article.definite on stem_note');
const DEF_ART = `00 bar '001P1L1M1'
01 bar.voice 'pi'
02 voice.offset tick=0
03 line.stem_note pitch='G5' articulatory='tailed'
04 article.definite 'tailed' constant=3
04 stem_note.chain _tmp_string='o'
05 chain.clause 0
06 seq.note letters='o' shift=0 length=1 netlength=1
`;
const defArtModel = buildModel(parseAstLog(DEF_ART));
const defSN = defArtModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
ok('definite prop flattened onto stem_note', defSN.tailed === 3);
ok('articulatory not in unknownProps', defSN.unknownProps?.articulatory === undefined);
section('stem_note weight');
const WEIGHT_SN = `00 bar '001P1L1M1'
01 bar.voice 'pi'
02 voice.offset tick=0
03 line.stem_note pitch='C4' weight=0.8
`;
const weightModel = buildModel(parseAstLog(WEIGHT_SN));
const weightSN = weightModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
ok('weight parsed', weightSN.weight === 0.8);
ok('weight null when absent', defSN.weight === null);
// ── Bar IDs ────────────────────────────────────────────────────────────────
// Bar IDs are opaque auto-increment strings; only the raw id string matters.
@@ -230,8 +257,8 @@ const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url);
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
// Mark only alpha as dirty, verify ki is preserved verbatim.
const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' }));
// All non-linked instruments are always re-serialized; linked+unmodified are passed through.
const patchInstruments = model.instruments.map(i => ({ ...i }));
const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments);
const patchedLines = patched.split('\n');
@@ -239,16 +266,13 @@ ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrum
ok('patched score still has instrument ki:', patchedLines.some(l => /^instrument\s+ki\s*:/.test(l)));
ok('patched score alpha block contains character:', patchedLines.some(l => l.trim() === 'character:'));
ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha'));
ok('patchScore log does not record ki (not dirty)', !patchLog.some(e => e.path?.includes('ki')));
ok('patchScore log records ki as changed', patchLog.some(e => e.path?.includes('ki')));
// Ki is clean — its block must appear verbatim (check a unique line from the original)
const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l)));
// Simpler: original ki block should still exist in patched
const kiOrigIdx = rawScore.indexOf('\ninstrument ki:');
const kiBlock = kiOrigIdx >= 0 ? rawScore.slice(kiOrigIdx + 1, rawScore.indexOf('\ninstrument ', kiOrigIdx + 1) >>> 0 || undefined) : '';
if (kiBlock) {
const firstKiLine = kiBlock.split('\n')[0];
ok('ki block preserved verbatim (first line)', patched.includes(firstKiLine));
// dev/piano is linked and unmodified — it should pass through verbatim
const pianoInstr = patchInstruments.find(i => i.name === 'dev/piano');
if (pianoInstr) {
ok('dev/piano is linked', pianoInstr.isLinked === true);
ok('dev/piano not in log (linked+unmodified)', !patchLog.some(e => e.path?.includes('dev/piano')));
}
// patched score must not be empty and must be shorter or same length as original + alpha export
@@ -421,7 +445,6 @@ voice soprano:
const dirtyBar = {
id: '001P1L1M1',
isDirty: true,
stressor: { groups: [[2, 3], [1]] },
tempoLevels: 140,
upperStressBound: null,
@@ -430,8 +453,7 @@ const dirtyBar = {
};
const cleanBar = {
id: '001P1L1M2',
isDirty: false,
stressor: null, tempoLevels: null,
stressor: null, tempoLevels: 100,
upperStressBound: null, lowerStressBound: null, tempoShape: null,
};
const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
@@ -439,11 +461,15 @@ const barPatchedLines = barPatched.split('\n');
ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140'));
ok('dirty bar stress_pattern updated', barPatched.includes('stress_pattern: 2,3;1'));
ok('dirty bar voice content preserved', barPatched.includes('- C4 4'));
ok('clean bar unchanged', barPatched.includes('beats_per_minute: 100'));
ok('clean bar BPM preserved via model', barPatched.includes('beats_per_minute: 100'));
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2);
ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1'));
ok('bar log has pass-through info entry', barLog.some(e => e.level === 'info' && e.message?.includes('passed through')));
ok('bar log records clean bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M2'));
// Minimal instrument stub reused across patchScore tests that don't care about instrument content.
const STUB_ALPHA = { name: 'alpha', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
const STUB_KI = { name: 'ki', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
// ── patchScore metadata ────────────────────────────────────────────────────
section('patchScore metadata');
@@ -456,26 +482,26 @@ instrument alpha:
A: "1:0,10;1,0"
`;
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
const { text: metaPatched } = patchScore(META_SCORE, [], [], updatedInfo);
const { text: metaPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], updatedInfo);
ok('existing title replaced', metaPatched.includes('title: New Title'));
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
ok('new key prepended', metaPatched.includes('encrypter: Me'));
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
ok('instrument block present', metaPatched.includes('instrument alpha:'));
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [STUB_KI], [], { title: 'Fugue', composer: 'Bach' });
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
const { text: nullInfoPatched } = patchScore(META_SCORE, [], [], null);
const { text: nullInfoPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], null);
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
// ── patchScore articles ────────────────────────────────────────────────────
section('patchScore articles');
const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`;
const artModel = [{ name: 'f', isDirty: true, properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }];
const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [], [], null, artModel);
const artModel = [{ name: 'f', properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }];
const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [STUB_ALPHA], [], null, artModel);
ok('article block replaced', artPatched.includes('articles:'));
ok('updated value written', artPatched.includes('add_stress: 4'));
ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }'));
@@ -483,33 +509,34 @@ ok('instrument line still present', artPatched.includes('instrument alpha:'));
ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f'));
const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`;
const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [], [], null, artModel);
const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [STUB_ALPHA], [], null, artModel);
ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:'));
const artClean = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 3, scope: 'defaults' }] }];
const { text: artUntouched } = patchScore(ART_SCORE, [], [], null, artClean);
ok('clean articles section left verbatim', artUntouched.includes('{ add_stress: 3 }'));
// ── patchScore articlesModified flag ──────────────────────────────────────
section('patchScore articlesModified flag');
const artCleanForFlag = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 99, scope: 'defaults' }] }];
const { text: artFlagPatched, log: artFlagLog } = patchScore(ART_SCORE, [], [], null, artCleanForFlag, { articlesModified: true });
ok('articlesModified forces re-serialization', artFlagPatched.includes('add_stress: 99'));
ok('articlesModified: old flow-style replaced', !artFlagPatched.includes('{ add_stress: 3 }'));
// ── patchScore articles with overwrites ───────────────────────────────────
section('patchScore articles with overwrites');
const OVW_SCORE = `articles:\n f:\n add_stress: 3\n\ninstrument alpha:\n character:\n`;
const ovwModel = [{ name: 'f', properties: [
{ name: 'add_stress', value: 4, scope: 'defaults' },
{ name: 'add_stress', scope: 'overwrites' },
]}];
const { text: ovwPatched } = patchScore(OVW_SCORE, [STUB_ALPHA], [], null, ovwModel);
ok('overwrites emit -important block', ovwPatched.includes('-important:'));
ok('overwrites list entry present', ovwPatched.includes('- add_stress'));
// ── patchScore deleted bars ────────────────────────────────────────────────
section('patchScore deleted bars');
const delBar1 = { id: '001P1L1M1', isDirty: true, deleted: true, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const delBar2 = { id: '001P1L1M2', isDirty: false, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar1, delBar2]);
const delBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar2]);
ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1'));
ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2'));
ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1);
// ── patchScore new bars ────────────────────────────────────────────────────
section('patchScore new bars');
const newBarEntry = { id: '001P1L1M3', isDirty: true, isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [newBarEntry]);
const existingBar1 = { id: '001P1L1M1', stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const existingBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: 100, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const newBarEntry = { id: '001P1L1M3', _isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [existingBar1, existingBar2, newBarEntry]);
ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3'));
ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120'));
ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3);
@@ -519,13 +546,12 @@ ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.p
section('patchScore deleted instrument');
const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`;
const delInstrList = [
{ name: 'alpha', deleted: true, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
{ name: 'ki', deleted: false, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
{ name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
];
const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList);
ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:'));
ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:'));
ok('deleted instrument logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('alpha') && e.path?.includes('deleted')));
ok('ki re-serialized and logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('ki')));
// ── Motif parsing ─────────────────────────────────────────────────────────
section('Motif parsing — dynamic motif');