diff --git a/test-parser.mjs b/test-parser.mjs index 2c39db9..3a12ac5 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'fs'; import { parseAstLog, buildModel } from './static/ast-parser.js'; -import { exportInstrument, patchScore } from './static/exporter.js'; +import { exportInstrument, patchScore, stressorToString } from './static/exporter.js'; const FIXTURE = new URL('./fixtures/ast.log', import.meta.url); const text = readFileSync(FIXTURE, 'utf8'); @@ -245,6 +245,164 @@ 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); + // ── Summary ──────────────────────────────────────────────────────────────── console.log(`\n══ ${pass} passed, ${fail} failed ══\n`); process.exit(fail > 0 ? 1 : 0);