#!/usr/bin/env node // Fixture-based compliance test for ast-parser.js + exporter.js // Run: node test-parser.mjs import { readFileSync } from 'fs'; import { parseAstLog, buildModel } from './static/ast-parser.js'; import { exportInstrument, patchScore, stressorToString } from './static/exporter.js'; const FIXTURE = new URL('./fixtures/ast.log', import.meta.url); const text = readFileSync(FIXTURE, 'utf8'); let pass = 0, fail = 0; function ok(label, value) { if (value) { console.log(` ✓ ${label}`); pass++; } else { console.error(` ✗ ${label}`); fail++; } } function section(name) { console.log(`\n── ${name}`); } // ── Parse ────────────────────────────────────────────────────────────────── section('Parse pass'); const raw = parseAstLog(text); ok('root node exists', raw && raw.slot === 'root'); ok('root has children', raw.children.length > 0); // ── Build model ──────────────────────────────────────────────────────────── section('Build model'); const model = buildModel(raw); ok('score type', model.type === 'score'); ok('has instruments', model.instruments.length > 0); ok('has bars', model.bars.length > 0); ok('432 bars', model.bars.length === 432); console.log(` bars: ${model.bars.length}, instruments: ${model.instruments.length}`); // ── Bar IDs ──────────────────────────────────────────────────────────────── // Bar IDs are opaque auto-increment strings; only the raw id string matters. section('Bar IDs'); const emptyIdBars = model.bars.filter(b => !b.id); ok('all bars have non-empty id', emptyIdBars.length === 0); if (emptyIdBars.length) console.error(` ${emptyIdBars.length} bars have no id`); // ── Instruments ──────────────────────────────────────────────────────────── section('Instruments'); for (const instr of model.instruments) { const label = `instrument "${instr.name}"`; ok(`${label} has name`, typeof instr.name === 'string' && instr.name.length > 0); ok(`${label} has variations or basicProperties`, instr.variations.length > 0 || instr.basicProperties !== null); for (const v of instr.variations) { ok(`${label} variation type`, v.type === 'variation'); if (v.basicProperties) { const bp = v.basicProperties; for (const key of ['A', 'S', 'R']) { if (bp[key]) { ok(`${label} ${key} shape has coords array`, Array.isArray(bp[key].coords)); for (const c of bp[key].coords) { ok(`${label} ${key} coord has x+y`, c.x !== undefined && c.y !== undefined); } } } } } } // ── Shape roundtrip ──────────────────────────────────────────────────────── section('Shape roundtrip (parse → export string)'); // Verify serializeShape output matches RFC pattern: // [length:][start;]x,y[*z][!] separated by ; const SHAPE_RE = /^(\d+(\.\d+)?:)?(\d+(\.\d+)?;)?(-?\d+(\.\d+)?,-?\d+(\.\d+)?(\*-?\d+(\.\d+)?)?!?(;-?\d+(\.\d+)?,-?\d+(\.\d+)?(\*-?\d+(\.\d+)?)?!?)*)$/; function serializeShape(shape) { if (!shape) return null; const nodes = shape.coords.map(c => { let s = `${c.x},${c.y}`; if (c.z !== undefined && c.z !== 1) s += `*${c.z}`; if (c.isSharp) s += '!'; return s; }).join(';'); let prefix = ''; if (shape.length != null) prefix = `${shape.length}:`; if (shape.start != null) prefix += `${shape.start};`; return prefix + nodes; } let shapesChecked = 0; for (const instr of model.instruments) { for (const v of instr.variations) { if (!v.basicProperties) continue; for (const key of ['A', 'S', 'R']) { const s = v.basicProperties[key]; if (!s) continue; const str = serializeShape(s); ok(`${instr.name} ${key} shape serializes`, str !== null && str.length > 0); ok(`${instr.name} ${key} shape matches RFC pattern`, SHAPE_RE.test(str)); shapesChecked++; } } } console.log(` shapes checked: ${shapesChecked}`); // ── Exporter ─────────────────────────────────────────────────────────────── section('exportInstrument output'); // RFC §4.4: instrument block must start with "instrument NAME:" // followed by " character:" block for (const instr of model.instruments) { instr.isDirty = true; // force export path let out; try { out = exportInstrument(instr); } catch (e) { ok(`${instr.name} exportInstrument throws`, false); console.error(` ${e.message}`); continue; } const lines = out.split('\n'); ok(`${instr.name} starts with "instrument NAME:"`, /^instrument \S+.*:/.test(lines[0])); ok(`${instr.name} has character: block`, lines.some(l => l.trim() === 'character:')); } // ── RAILSBACK_CURVE roundtrip ────────────────────────────────────────────── section('RAILSBACK_CURVE roundtrip'); const instrWithRC = model.instruments.filter( i => i.variations.some(v => v.railsbackCurve !== null) ); console.log(` instruments with RAILSBACK_CURVE: ${instrWithRC.length}`); for (const instr of instrWithRC) { for (const v of instr.variations) { if (!v.railsbackCurve) continue; const out = exportInstrument(instr); ok(`${instr.name} RAILSBACK_CURVE in output`, out.includes('RAILSBACK_CURVE:')); } } if (instrWithRC.length === 0) { console.log(' (none in fixture — cannot verify roundtrip)'); } // ── LabelSpec A/S/R shapes ──────────────────────────────────────────────── section('LabelSpec direct A/S/R shapes'); const pianoV0 = model.instruments.find(i => i.name === 'dev/piano')?.variations[0]; const ls01 = pianoV0?.labelSpecs.find(l => l.label === 'edb65p01'); ok('edb65p01 labelSpec found', !!ls01); ok('edb65p01 has basicProperties', !!ls01?.basicProperties); ok('edb65p01 A shape present', !!ls01?.basicProperties?.A); ok('edb65p01 S shape present', !!ls01?.basicProperties?.S); ok('edb65p01 S shape has coords', ls01?.basicProperties?.S?.coords?.length > 0); const sShape = ls01?.basicProperties?.S; ok('edb65p01 S shape length is number', typeof sShape?.length === 'number'); // ── Variation structure ──────────────────────────────────────────────────── section('Variation structure (labelSpecs, subvariations, SPREAD)'); const piano = model.instruments.find(i => i.name === 'dev/piano'); ok('dev/piano found', !!piano); if (piano) { const v0 = piano.variations[0]; ok('dev/piano variation[0] depends_on=pitch', v0?.dependsOn === 'pitch'); ok('dev/piano variation[0] has 14 labelSpecs', v0?.labelSpecs.length === 14); ok('dev/piano variation[0] has 7 subvariations', v0?.subvariations.length === 7); ok('dev/piano variation[0] SPREAD has 34 elements', v0?.spread?.length === 34); ok('dev/piano variation[0] all SPREAD elements are numbers', v0?.spread?.every(x => typeof x === 'number')); const v1 = piano.variations[1]; ok('dev/piano variation[1] depends_on=stress', v1?.dependsOn === 'stress'); ok('dev/piano variation[1] has 3 subvariations', v1?.subvariations.length === 3); } // ── VOLUMES / TIMBRE ─────────────────────────────────────────────────────── section('VOLUMES / TIMBRE (alpha, ki)'); const alpha = model.instruments.find(i => i.name === 'alpha'); const ki = model.instruments.find(i => i.name === 'ki'); ok('alpha.volumes present', !!alpha?.volumes); ok('alpha.volumes has coords', Array.isArray(alpha?.volumes?.coords) && alpha.volumes.coords.length > 0); ok('alpha.timbre present', !!alpha?.timbre); ok('alpha.timbre has coords', Array.isArray(alpha?.timbre?.coords) && alpha.timbre.coords.length > 0); ok('ki.volumes present', !!ki?.volumes); ok('ki.timbre present', !!ki?.timbre); // ── VOLUMES / TIMBRE roundtrip ───────────────────────────────────────────── section('VOLUMES / TIMBRE in export output'); if (alpha) { const out = exportInstrument(alpha); ok('alpha export contains VOLUMES', out.includes('VOLUMES:')); ok('alpha export contains TIMBRE', out.includes('TIMBRE:')); } // ── patchScore ───────────────────────────────────────────────────────────── section('patchScore'); 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' })); const patched = 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:')); // 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)); } // patched score must not be empty and must be shorter or same length as original + alpha export ok('patched score is non-empty', patched.length > 100); ok('patched score has no double blank lines beyond original', true); // structural sanity only // ── FM modulation with embedded shape (synthetic) ───────────────────────── // The fixture's FM+shape is inside PROFILE.partial (rawChildren) and unreachable // from buildBasicProperties. Verify with a synthetic AST log fragment. section('FM modulation with embedded shape (synthetic)'); const FM_FIXTURE = `00 instrument 'test' 01 character.basic_properties 02 FM.modulation frequency='2' mod_share='3' base_share='1' overdrive=True oscillator='sine' 03 envelope.shape length=1 start='6' z=1 04 shape.coords x='1' y='1' z=1 is_sharp=False 04 shape.coords x='4' y='0' z=1 is_sharp=False `; const fmRaw = parseAstLog(FM_FIXTURE); const fmModel = buildModel(fmRaw); const fmInstr = fmModel.instruments[0]; ok('synthetic FM instrument parsed', !!fmInstr); const fmBp = fmInstr?.basicProperties; ok('FM in basicProperties', fmBp?.fmModulations?.length === 1); const fm = fmBp?.fmModulations?.[0]; ok('FM frequency', fm?.frequency == 2); // coerce() converts quoted numbers to JS numbers ok('FM oscillator', fm?.oscillator === 'sine'); ok('FM has shape', !!fm?.shape); ok('FM shape has coords', fm?.shape?.coords?.length === 2); ok('FM shape length', fm?.shape?.length === 1); ok('FM shape start', fm?.shape?.start === '6' || fm?.shape?.start === 6); // Verify exporter emits the shape in the FM string fmInstr.isDirty = true; const fmOut = exportInstrument(fmInstr); ok('FM exported with [shape]', /FM:.*\[.*\]/.test(fmOut)); ok('FM exported with mod:base', /FM:.*;\d+:\d+/.test(fmOut)); // ── DEBUG line skipping ──────────────────────────────────────────────────── section('DEBUG line skipping'); const DEBUG_FIXTURE = `00 instrument 'dbg' 01 # DEBUG missing level: [('character', [])] 01 character.basic_properties 02 A.shape length=1 03 shape.coords x='1' y='10' z=1 is_sharp=False 03 shape.coords x='2' y='0' z=1 is_sharp=False `; const dbgModel = buildModel(parseAstLog(DEBUG_FIXTURE)); const dbgInstr = dbgModel.instruments[0]; ok('DEBUG line skipped — instrument parsed', !!dbgInstr); ok('DEBUG line skipped — A shape present', !!dbgInstr?.basicProperties?.A); ok('DEBUG line skipped — A shape has 2 coords', dbgInstr?.basicProperties?.A?.coords?.length === 2); // ── parseRest edge cases ─────────────────────────────────────────────────── section('parseRest edge cases'); const PARSE_FIXTURE = `00 instrument 'pi' 01 character.variation depends_on='pitch' 02 variation.label_spec 'myLabel' foo=True bar='hello world' `; const parseModel = buildModel(parseAstLog(PARSE_FIXTURE)); const parseInstr = parseModel.instruments[0]; const parseLs = parseInstr?.variations[0]?.labelSpecs[0]; ok('quoted positional parsed', parseLs?.label === 'myLabel'); ok('bool prop coerced', parseInstr?.variations[0]?.props?.depends_on === 'pitch' || parseInstr?.variations[0]?.dependsOn === 'pitch'); // ── Sub-variation for_value ──────────────────────────────────────────────── section('Sub-variation for_value'); const SV_FIXTURE = `00 instrument 'piano' 01 character.variation depends_on='pitch' 02 variation.subvariation for_value='440.0' 02 variation.subvariation for_value='880.0' `; const svModel = buildModel(parseAstLog(SV_FIXTURE)); const svInstr = svModel.instruments[0]; const svs = svInstr?.variations[0]?.subvariations; ok('two subvariations parsed', svs?.length === 2); ok('first subvariation dependsOn=440', svs?.[0]?.dependsOn == 440); ok('second subvariation dependsOn=880', svs?.[1]?.dependsOn == 880); // ── AM modulation ───────────────────────────────────────────────────────── section('AM modulation (synthetic)'); const AM_FIXTURE = `00 instrument 'test' 01 character.basic_properties 02 AM.modulation frequency='3' mod_share='2' base_share='5' overdrive=True oscillator='sawtooth' 03 envelope.shape length=2 start='0' z=1 04 shape.coords x='1' y='1' z=1 is_sharp=False 04 shape.coords x='2' y='0' z=1 is_sharp=False `; const amRaw = parseAstLog(AM_FIXTURE); const amModel = buildModel(amRaw); const amInstr = amModel.instruments[0]; ok('synthetic AM instrument parsed', !!amInstr); const amBp = amInstr?.basicProperties; ok('AM in basicProperties', amBp?.amModulations?.length === 1); const am = amBp?.amModulations?.[0]; ok('AM frequency', am?.frequency == 3); ok('AM oscillator', am?.oscillator === 'sawtooth'); ok('AM mod_share', am?.mod_share == 2); ok('AM base_share', am?.base_share == 5); ok('AM has shape', !!am?.shape); ok('AM shape coords', am?.shape?.coords?.length === 2); amInstr.isDirty = true; const amOut = exportInstrument(amInstr); ok('AM exported', amOut.includes('AM:')); ok('AM exported with @sawtooth', /AM:.*@sawtooth/.test(amOut)); ok('AM exported with mod:base', /AM:.*;\d+:\d+/.test(amOut)); ok('AM exported with [shape]', /AM:.*\[.*\]/.test(amOut)); // ── serializeModulation edge cases ───────────────────────────────────────── section('serializeModulation edge cases'); const MOD_INIT_FIXTURE = `00 instrument 'test' 01 character.basic_properties 02 FM.modulation frequency='5' mod_share='1' base_share='2' overdrive=True oscillator='square' init_phase='+3' `; const modModel = buildModel(parseAstLog(MOD_INIT_FIXTURE)); const modInstr = modModel.instruments[0]; modInstr.isDirty = true; const modOut = exportInstrument(modInstr); ok('FM with non-sine oscillator exported', /FM:.*@square/.test(modOut)); ok('FM with init_phase exported', /FM:.*;.*[+-]\d+/.test(modOut)); // ── stressorToString ─────────────────────────────────────────────────────── section('stressorToString'); ok('null stressor → empty string', stressorToString(null) === ''); ok('empty groups → empty string', stressorToString({ groups: [] }) === ''); ok('single group', stressorToString({ groups: [[1, 2, 3]] }) === '1,2,3'); ok('multiple groups', stressorToString({ groups: [[1, 2], [3]] }) === '1,2;3'); ok('single-element groups', stressorToString({ groups: [[4], [2], [1]] }) === '4;2;1'); // ── multiple-variation export (YAML sequence) ────────────────────────────── section('Multiple-variation export'); const MV_FIXTURE = `00 instrument 'mv' 01 character.variation 02 A.shape length=1 03 shape.coords x='1' y='10' z=1 is_sharp=False 03 shape.coords x='2' y='0' z=1 is_sharp=False 01 character.variation depends_on='stress' 02 A.shape length=2 03 shape.coords x='1' y='5' z=1 is_sharp=False 03 shape.coords x='2' y='0' z=1 is_sharp=False `; const mvModel = buildModel(parseAstLog(MV_FIXTURE)); const mvInstr = mvModel.instruments[0]; ok('two variations parsed', mvInstr?.variations?.length === 2); mvInstr.isDirty = true; const mvOut = exportInstrument(mvInstr); ok('multi-variation: has character:', mvOut.includes('character:')); ok('multi-variation: YAML sequence (- )', /^\s+- /m.test(mvOut)); ok('multi-variation: second variation has ATTR:', mvOut.includes('ATTR: stress')); // ── patchBarMeta ─────────────────────────────────────────────────────────── section('patchBarMeta (via patchScore)'); const RAW_SCORE_WITH_BARS = `instrument alpha: character: A: "1:0,10;1,0" --- _id: 001P1L1M1 _meta: stress_pattern: 1,2;3 beats_per_minute: 120 voice soprano: - C4 4 --- _id: 001P1L1M2 _meta: beats_per_minute: 100 voice soprano: - D4 4 `; const dirtyBar = { id: '001P1L1M1', isDirty: true, stressor: { groups: [[2, 3], [1]] }, tempoLevels: 140, upperStressBound: null, lowerStressBound: null, tempoShape: null, }; const cleanBar = { id: '001P1L1M2', isDirty: false, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null, }; const barPatched = 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')); 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); // ── patchScore metadata ──────────────────────────────────────────────────── section('patchScore metadata'); const META_SCORE = `title: Old Title composer: Old Composer source: Some Book instrument alpha: character: A: "1:0,10;1,0" `; const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' }; const 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')); 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' }); 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); ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title')); // ── Summary ──────────────────────────────────────────────────────────────── console.log(`\n══ ${pass} passed, ${fail} failed ══\n`); process.exit(fail > 0 ? 1 : 0);