Article export + Bootstrap toggle + export change log

- patchScore() now accepts articles[] and returns { text, log } instead
  of a plain string. Dirty articles are re-serialized into the articles:
  block (block style, replacing any existing flow-style entries). Clean
  articles bypass the patcher entirely.
- article.isDirty is set on direct property edits so the exporter can
  distinguish directly changed entities from propagated dirty state.
- patchScore() log collects { level:'changed', path } for each patched
  article/instrument/bar, and a { level:'info', message } entry for the
  count of bar documents passed through unchanged.
- CP pane shows the export log above the status poller after each export.
- Article scope toggle replaced with Bootstrap form-check form-switch;
  hand-rolled .se-toggle CSS removed.
- Test suite updated for { text, log } return; 11 new assertions (188 total).
This commit is contained in:
c0dev0id
2026-06-30 06:27:12 +02:00
parent 5bec1a1d1e
commit 86b14dfaa3
6 changed files with 132 additions and 60 deletions

View File

@@ -230,12 +230,14 @@ 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' }));
const patched = patchScore(rawScore, patchInstruments);
const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments);
const patchedLines = patched.split('\n');
ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrument\s+alpha\s*:/.test(l)));
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')));
// 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)));
@@ -430,7 +432,7 @@ const cleanBar = {
stressor: null, tempoLevels: null,
upperStressBound: null, lowerStressBound: null, tempoShape: null,
};
const barPatched = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
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'));
@@ -438,6 +440,8 @@ ok('dirty bar voice content preserved', barPatched.includes('- C4 4'));
ok('clean bar unchanged', 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')));
// ── patchScore metadata ────────────────────────────────────────────────────
section('patchScore metadata');
@@ -450,7 +454,7 @@ instrument alpha:
A: "1:0,10;1,0"
`;
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
const metaPatched = patchScore(META_SCORE, [], [], updatedInfo);
const { text: metaPatched } = patchScore(META_SCORE, [], [], 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'));
@@ -458,13 +462,32 @@ ok('new key prepended', metaPatched.includes('encrypter: Me'));
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
const noTitlePatched = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
const nullInfoPatched = patchScore(META_SCORE, [], [], null);
const { text: nullInfoPatched } = patchScore(META_SCORE, [], [], 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);
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 }'));
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);
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 }'));
// ── Motif parsing ─────────────────────────────────────────────────────────
section('Motif parsing — dynamic motif');
const DYN_MOTIF = `00 bar '001P1L1M1'