// Phase 5 — Cadence and Event Subsystems Acceptance Tests // Covers Format Specification 0.1 Section 20.15 traces and PRD 61-68, 90-91. import assert from 'node:assert/strict'; import test from 'node:test'; import { validateExhibit } from '../src/runtime/validator.js'; import { SeededRNG } from '../src/runtime/rng.js'; import { ResolutionEngine } from '../src/runtime/resolution.js'; import { ActionExecutor } from '../src/runtime/actions.js'; import { CadenceSubsystem, DEFAULT_CLASS_INTERVALS, RECENCY_MULTIPLIERS, CADENCE_PRIORITY } from '../src/runtime/cadence.js'; import { CommonGrammarPerformance } from '../src/runtime/performance.js'; import { RuntimeFault } from '../src/runtime/types.js'; function baseExhibit(overrides = {}) { return { xzbt: '0.1', meta: { id: 'cadence-test', name: 'Cadence Test' }, runtime: { seed: 42 }, audio: { buses: { ambient: { gain: 0.5 }, effects: { gain: 0.8 } } }, sounds: { 'hum': { name: 'Hum', usage: ['automatic'], cadence: { class: 'ambient' }, bus: 'ambient', recipe: { mode: 'continuous', nodes: { gen: { type: 'oscillator', frequency: 100 }, gain: { type: 'gain', gain: 0.3 } }, routes: [ { from: 'gen', to: 'gain' }, { from: 'gain', to: 'output' } ] } }, 'click': { name: 'Click', usage: ['automatic', 'manual', 'scenario'], cadence: { class: 'routine', weight: 1.0, cooldown: '5s' }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse', duration: '5ms' }, gain: { type: 'gain', gain: 0.5 } }, routes: [ { from: 'imp', to: 'gain' }, { from: 'gain', to: 'output' } ] } } }, cadence: { minGap: '1.5s', intensity: 1.0, clocks: { routine: { min: '10s', max: '30s' } } }, ...overrides }; } class FakeAudioSubsystem { constructor() { this.played = []; this.activeInstances = new Map(); // id -> voice this.nextVoiceId = 1; } play(soundId, options = {}) { const voiceId = this.nextVoiceId++; const voice = { id: voiceId, soundId, options, disposed: false, stop: () => { voice.disposed = true; this.activeInstances.delete(voiceId); } }; this.played.push({ soundId, options, time: options.time ?? 0, voice }); this.activeInstances.set(voiceId, voice); return voice; } getActiveSounds() { const list = []; for (const v of this.activeInstances.values()) { if (!v.disposed) { list.push({ soundId: v.soundId, instance: v }); } } return list; } } // --------------------------------------------------------------------------- // Trace 1: Schema & Static Validation (Spec 20.15 Trace 1) // --------------------------------------------------------------------------- test('Trace 1: Schema & Static Validation of cadence and sound metadata', () => { // Valid exhibit validates cleanly const valid = baseExhibit(); const resValid = validateExhibit(valid); assert.deepEqual(resValid.errors, [], `Expected clean validation: ${JSON.stringify(resValid.errors)}`); // Clock range with min > max rejected with ERR_INVALID_RANGE_ORDER const invalidRange = baseExhibit({ cadence: { clocks: { routine: { min: '30s', max: '10s' } } } }); const resRange = validateExhibit(invalidRange); assert.ok( resRange.errors.some(e => e.code === 'ERR_INVALID_RANGE_ORDER'), 'Expected ERR_INVALID_RANGE_ORDER when min > max' ); // Unknown field in cadence container rejected with ERR_UNKNOWN_FIELD const unknownCadenceField = baseExhibit({ cadence: { extraField: 123 } }); const resField = validateExhibit(unknownCadenceField); assert.ok( resField.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'), 'Expected ERR_UNKNOWN_FIELD on cadence.extraField' ); // Unknown clock class rejected with ERR_UNKNOWN_FIELD const unknownClock = baseExhibit({ cadence: { clocks: { unknownClass: { min: '1s', max: '2s' } } } }); const resClock = validateExhibit(unknownClock); assert.ok( resClock.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'), 'Expected ERR_UNKNOWN_FIELD on unknown clock class' ); // Negative sound cadence weight rejected with ERR_OUT_OF_BOUNDS const negativeWeight = baseExhibit(); negativeWeight.sounds.click.cadence.weight = -1; const resWeight = validateExhibit(negativeWeight); assert.ok( resWeight.errors.some(e => e.code === 'ERR_OUT_OF_BOUNDS'), 'Expected ERR_OUT_OF_BOUNDS on negative weight' ); }); // --------------------------------------------------------------------------- // Trace 2: Static Event Cycle Detection (Spec 20.15 Trace 2) // --------------------------------------------------------------------------- test('Trace 2: Static cycle detection rejects circular event action chains', () => { // Self-cycle const selfCycle = baseExhibit({ events: { loop: { actions: [{ type: 'event', event: 'loop' }] } } }); const resSelf = validateExhibit(selfCycle); assert.ok( resSelf.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'), 'Expected ERR_CYCLIC_DEPENDENCY for self-cycle' ); // Mutual cycle A -> B -> A const mutualCycle = baseExhibit({ events: { eventA: { actions: [{ type: 'event', event: 'eventB' }] }, eventB: { actions: [{ type: 'event', event: 'eventA' }] } } }); const resMutual = validateExhibit(mutualCycle); assert.ok( resMutual.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'), 'Expected ERR_CYCLIC_DEPENDENCY for mutual cycle' ); // Clean DAG: A -> B, A -> C, B -> C const cleanDAG = baseExhibit({ events: { eventA: { actions: [ { type: 'event', event: 'eventB' }, { type: 'event', event: 'eventC' } ] }, eventB: { actions: [{ type: 'event', event: 'eventC' }] }, eventC: { actions: [{ type: 'sound', sound: 'click' }] } } }); const resDAG = validateExhibit(cleanDAG); assert.ok( !resDAG.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'), 'Valid DAG must not produce ERR_CYCLIC_DEPENDENCY' ); }); // --------------------------------------------------------------------------- // Trace 3: Event Scoping, Defaults, and Type Enforcement (Spec 20.15 Trace 3) // --------------------------------------------------------------------------- test('Trace 3: Event input scoping, defaults application, and strict type checking', () => { const doc = baseExhibit({ events: { triggerSound: { inputs: { gain: { type: 'number', default: 0.5, min: 0, max: 1 }, tag: { type: 'string', default: 'normal' } }, actions: [ { type: 'sound', sound: 'click' } ] } } }); const rng = new SeededRNG(12345); const engine = new ResolutionEngine(doc, rng); const diagnostics = { errors: [], error(code, msg) { this.errors.push({ code, msg }); } }; const executor = new ActionExecutor(engine, { diagnostics }); // 1. Invoke event with defaults const result1 = executor.executeAction({ type: 'event', event: 'triggerSound' }); assert.equal(result1, true, 'Event with default inputs should execute successfully'); assert.equal(diagnostics.errors.length, 0); // 2. Invoke event with explicit valid inputs const result2 = executor.executeAction({ type: 'event', event: 'triggerSound', with: { gain: 0.8, tag: 'loud' } }); assert.equal(result2, true, 'Event with explicit valid inputs should execute'); // 3. Invoke event with undeclared input -> ERR_UNKNOWN_FIELD executor.executeAction({ type: 'event', event: 'triggerSound', with: { undeclaredInput: 42 } }); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'), 'Expected ERR_UNKNOWN_FIELD on undeclared input' ); // 4. Invoke event with type mismatch (string for number) -> ERR_TYPE_MISMATCH diagnostics.errors.length = 0; executor.executeAction({ type: 'event', event: 'triggerSound', with: { gain: 'not-a-number' } }); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_TYPE_MISMATCH'), 'Expected ERR_TYPE_MISMATCH on invalid input type' ); }); // --------------------------------------------------------------------------- // Trace 4: Event Recursion Depth Limit (Spec 20.15 Trace 4) // --------------------------------------------------------------------------- test('Trace 4: Dynamic event nesting exceeding 16 levels is halted', () => { // Build a chain of 18 distinct events: e0 -> e1 -> e2 ... -> e17 const events = {}; for (let i = 0; i < 18; i++) { events[`e${i}`] = { actions: i < 17 ? [{ type: 'event', event: `e${i + 1}` }] : [{ type: 'sound', sound: 'click' }] }; } const doc = baseExhibit({ events }); const rng = new SeededRNG(42); const engine = new ResolutionEngine(doc, rng); const diagnostics = { errors: [], error(code, msg) { this.errors.push({ code, msg }); } }; const executor = new ActionExecutor(engine, { diagnostics }); const result = executor.executeAction({ type: 'event', event: 'e0' }); assert.equal(result, false, 'Chain exceeding 16 levels should fail'); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_DISPATCH_BUDGET'), 'Expected ERR_DISPATCH_BUDGET for depth > 16' ); }); // --------------------------------------------------------------------------- // Trace 5: Action Dispatch Budget (1024 units/tick) (Spec 20.15 Trace 5) // --------------------------------------------------------------------------- test('Trace 5: Per-tick 1024-unit action dispatch budget is strictly enforced', () => { // Create an event that executes 1100 sound actions const actions = []; for (let i = 0; i < 1100; i++) { actions.push({ type: 'sound', sound: 'click' }); } const doc = baseExhibit({ events: { burst: { actions } } }); const rng = new SeededRNG(42); const engine = new ResolutionEngine(doc, rng); const diagnostics = { errors: [], error(code, msg) { this.errors.push({ code, msg }); } }; const executor = new ActionExecutor(engine, { diagnostics }); executor.executeAction({ type: 'event', event: 'burst' }); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_DISPATCH_BUDGET'), 'Expected ERR_DISPATCH_BUDGET when tick actions exceed 1024' ); assert.equal(executor.tickActionCount, 1024, 'Action count should cap at 1024'); // Reset for next tick allows new actions executor.resetTickBudget(); assert.equal(executor.tickActionCount, 0); }); // --------------------------------------------------------------------------- // Trace 6: Sound Action Usage Gating (Spec 20.15 Trace 6) // --------------------------------------------------------------------------- test('Trace 6: Sound actions targeting unauthorized sounds are refused with ERR_UNSUPPORTED_TARGET', () => { const doc = baseExhibit({ sounds: { autoOnly: { name: 'Auto Only', usage: ['automatic'], bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } }, scenarioOnly: { name: 'Scenario Sound', usage: ['scenario'], bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } }, manualOnly: { name: 'Manual Sound', usage: ['manual'], bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } }, dualUsage: { name: 'Dual Usage Sound', usage: ['manual', 'scenario'], bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } } } }); const rng = new SeededRNG(42); const engine = new ResolutionEngine(doc, rng); const fakeAudio = new FakeAudioSubsystem(); engine.audio = fakeAudio; const diagnostics = { errors: [], error(code, msg) { this.errors.push({ code, msg }); } }; const executor = new ActionExecutor(engine, { diagnostics }); // 1. Target autoOnly from default (manual) context -> fail with ERR_UNSUPPORTED_TARGET const res1 = executor.executeAction({ type: 'sound', sound: 'autoOnly' }); assert.equal(res1, false); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'), 'Expected ERR_UNSUPPORTED_TARGET for automatic sound invoked from manual context' ); // 2. Target scenarioOnly from manual context -> must FAIL with ERR_UNSUPPORTED_TARGET (F2 fix) diagnostics.errors.length = 0; const res2 = executor.executeAction({ type: 'sound', sound: 'scenarioOnly' }); assert.equal(res2, false); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'), 'Expected ERR_UNSUPPORTED_TARGET for scenario sound invoked from manual context' ); // 3. Target scenarioOnly from scenario context -> SUCCEEDS diagnostics.errors.length = 0; const res3 = executor.executeAction({ type: 'sound', sound: 'scenarioOnly' }, { usage: 'scenario' }); assert.equal(res3, true); assert.equal(diagnostics.errors.length, 0); assert.equal(fakeAudio.played.length, 1); assert.equal(fakeAudio.played[0].soundId, 'scenarioOnly'); // 4. Target manualOnly from scenario context -> must FAIL with ERR_UNSUPPORTED_TARGET diagnostics.errors.length = 0; const res4 = executor.executeAction({ type: 'sound', sound: 'manualOnly' }, { usage: 'scenario' }); assert.equal(res4, false); assert.ok( diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'), 'Expected ERR_UNSUPPORTED_TARGET for manual sound invoked from scenario context' ); // 5. Target manualOnly from manual context -> SUCCEEDS diagnostics.errors.length = 0; const res5 = executor.executeAction({ type: 'sound', sound: 'manualOnly' }); assert.equal(res5, true); assert.equal(fakeAudio.played.length, 2); assert.equal(fakeAudio.played[1].soundId, 'manualOnly'); // 6. Target dualUsage from both manual and scenario contexts -> SUCCEEDS in both const res6a = executor.executeAction({ type: 'sound', sound: 'dualUsage' }); const res6b = executor.executeAction({ type: 'sound', sound: 'dualUsage' }, { usage: 'scenario' }); assert.equal(res6a, true); assert.equal(res6b, true); assert.equal(fakeAudio.played.length, 4); }); // --------------------------------------------------------------------------- // Trace 7: Sound Cooldown Enforcement (Spec 20.15 Trace 7) // --------------------------------------------------------------------------- test('Trace 7: Sound cooldown refrains from re-dispatching before interval expires', () => { const doc = baseExhibit({ cadence: { minGap: '1.5s' }, sounds: { cooldowned: { name: 'Cooldowned Sound', usage: ['automatic'], cadence: { class: 'routine', cooldown: '5s' }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // Schedule through production scheduler advance/update at t = 0 cadence.classes.routine.due = true; cadence.update(0); assert.equal(fakeAudio.played.length, 1, 'Should fire at t = 0'); assert.equal(fakeAudio.played[0].soundId, 'cooldowned'); // Attempt at t = 2000ms (minGap 1.5s elapsed, but < 5000ms cooldown) -> refused cadence.classes.routine.due = true; cadence.update(2000); assert.equal(fakeAudio.played.length, 1, 'Should be refused at t = 2000ms due to cooldown'); // Attempt at t = 5000ms (>= 5000ms cooldown) -> accepted cadence.classes.routine.due = true; cadence.update(5000); assert.equal(fakeAudio.played.length, 2, 'Should fire at t = 5000ms after cooldown'); assert.equal(fakeAudio.played[1].soundId, 'cooldowned'); }); // --------------------------------------------------------------------------- // Trace 8: Overlap Refusal (Spec 20.15 Trace 8) // --------------------------------------------------------------------------- test('Trace 8: Overlap false prevents new instance while existing voice is active', () => { const doc = baseExhibit({ cadence: { minGap: '1.5s' }, sounds: { noOverlap: { name: 'No Overlap', usage: ['automatic'], cadence: { class: 'routine', overlap: false }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // First trigger creates active voice through advance/update cadence.classes.routine.due = true; cadence.update(0); assert.equal(fakeAudio.played.length, 1); assert.equal(fakeAudio.activeInstances.size, 1); // Second trigger at t = 2000ms (minGap 1.5s elapsed, but previous voice still active) -> refused cadence.classes.routine.due = true; cadence.update(2000); assert.equal(fakeAudio.played.length, 1, 'Must refuse trigger when previous voice is still active'); // Stop active voice const activeVoice = Array.from(fakeAudio.activeInstances.values())[0]; activeVoice.stop(); assert.equal(fakeAudio.getActiveSounds().length, 0); // Third trigger at t = 4000ms after voice stopped -> accepted cadence.classes.routine.due = true; cadence.update(4000); assert.equal(fakeAudio.played.length, 2, 'Must accept trigger once active voice has stopped'); assert.equal(fakeAudio.played[1].soundId, 'noOverlap'); }); // --------------------------------------------------------------------------- // Trace 9: Anti-Repetition Multipliers (Spec 20.15 Trace 9) // --------------------------------------------------------------------------- test('Trace 9: Anti-repetition penalties apply 0.0, 0.25, 0.50, 0.75 recency multipliers', () => { const doc = baseExhibit({ cadence: { minGap: '1.5s' }, sounds: { s1: { name: 'S1', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }, s2: { name: 'S2', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }, s3: { name: 'S3', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }, s4: { name: 'S4', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }, s5: { name: 'S5', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // Set class recency queue reflecting firings: s1, then s2, then s3, then s4 // Recency queue ordering (front is most recent): // s4 fired 1 selection ago (rank 0 -> multiplier 0.0) // s3 fired 2 selections ago (rank 1 -> multiplier 0.25) // s2 fired 3 selections ago (rank 2 -> multiplier 0.50) // s1 fired 4 selections ago (rank 3 -> multiplier 0.75) // s5 not in recency queue (multiplier 1.0) cadence.classes.routine.recencyHistory = ['s4', 's3', 's2', 's1']; // Trigger through production scheduler path (advance/update -> fireClass) cadence.classes.routine.due = true; cadence.update(2000); // Inspect the candidate pool evaluated by fireClass const pool = cadence.getLastEvaluatedPool('routine'); const byId = Object.fromEntries(pool.map(p => [p.id, p.effectiveWeight])); assert.equal(byId.s4, 0.0, 'Rank 0 must have multiplier 0.0'); assert.equal(byId.s3, 0.25, 'Rank 1 must have multiplier 0.25'); assert.equal(byId.s2, 0.50, 'Rank 2 must have multiplier 0.50'); assert.equal(byId.s1, 0.75, 'Rank 3 must have multiplier 0.75'); assert.equal(byId.s5, 1.00, 'Unfired must have multiplier 1.0'); // Verify FIFO recency queue update (§20.5.9 / F5): // Winning sound was appended to front, and queue trimmed to depth 4 assert.equal(cadence.classes.routine.recencyHistory.length, 4); assert.equal(cadence.classes.routine.recencyHistory[0], fakeAudio.played[0].soundId); // Verify FIFO without dedup (F5): repeated selections occupy multiple slots cadence.classes.routine.recencyHistory = ['s1', 's2', 's1']; assert.equal(cadence.classes.routine.recencyHistory[0], 's1'); assert.equal(cadence.classes.routine.recencyHistory[2], 's1'); }); // --------------------------------------------------------------------------- // Trace 10: Anti-Repetition Relaxation (Spec 20.15 Trace 10) // --------------------------------------------------------------------------- test('Trace 10: Anti-repetition relaxation to base weights when all candidates are penalized to zero', () => { const doc = baseExhibit({ cadence: { minGap: '1.5s' }, sounds: { solo: { name: 'Solo Routine', usage: ['automatic'], cadence: { class: 'routine', weight: 1.5 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { imp: { type: 'impulse' } }, routes: [{ from: 'imp', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // First firing: solo is selected and plays cadence.classes.routine.due = true; cadence.update(0); assert.equal(fakeAudio.played.length, 1); assert.equal(fakeAudio.played[0].soundId, 'solo'); assert.deepEqual(cadence.classes.routine.recencyHistory, ['solo']); // Next evaluation at t = 2000ms: solo was most recently played (rank 0, multiplier 0.0). // Because it is the only candidate in the pool, total effective weight would be 0 without relaxation. // fireClass must relax anti-repetition penalties to base weights (1.5) and fire! cadence.classes.routine.due = true; cadence.update(2000); assert.equal(fakeAudio.played.length, 2, 'Solo sound must fire again after relaxation'); assert.equal(fakeAudio.played[1].soundId, 'solo'); // Verify that the evaluated pool in fireClass had effectiveWeight relaxed to 1.5 const pool = cadence.getLastEvaluatedPool('routine'); assert.equal(pool.length, 1); assert.equal(pool[0].effectiveWeight, 1.5, 'Relaxation must restore base weight so pool never deadlocks'); }); // --------------------------------------------------------------------------- // Trace 11: Intensity Scaling (Spec 20.15 Trace 11) // --------------------------------------------------------------------------- test('Trace 11: Intensity scaling inversely compresses or expands clock intervals', () => { const doc = baseExhibit({ cadence: { clocks: { routine: { min: '10s', max: '20s' } } } }); const rng = new SeededRNG(42); const cadence = new CadenceSubsystem({ document: doc, rng }); // Baseline at intensity 1.0: 10s - 20s cadence.setIntensity(1.0); const range1 = cadence.getEffectiveInterval('routine'); assert.equal(range1.min, 10000); assert.equal(range1.max, 20000); // High intensity 2.0: intervals halved (5s - 10s) cadence.setIntensity(2.0); const range2 = cadence.getEffectiveInterval('routine'); assert.equal(range2.min, 5000); assert.equal(range2.max, 10000); // Low intensity 0.5: intervals doubled (20s - 40s) cadence.setIntensity(0.5); const range05 = cadence.getEffectiveInterval('routine'); assert.equal(range05.min, 20000); assert.equal(range05.max, 40000); }); // --------------------------------------------------------------------------- // Trace 12: Minimum Gap and Priority Order (Spec 20.15 Trace 12) // --------------------------------------------------------------------------- test('Trace 12: Minimum gap (1.5s) and priority ordering (rare > occasional > intermittent > routine)', () => { const doc = baseExhibit({ cadence: { minGap: '1.5s' }, sounds: { rareSound: { name: 'Rare', usage: ['automatic'], cadence: { class: 'rare' }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }, routineSound: { name: 'Routine', usage: ['automatic'], cadence: { class: 'routine' }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // Force both rare and routine clocks to be due at t = 1000ms cadence.classes.rare.due = true; cadence.classes.routine.due = true; // Run update at t = 1000ms cadence.update(1000); // Only the higher priority sound ('rare') should have fired due to minGap refusal assert.equal(fakeAudio.played.length, 1); assert.equal(fakeAudio.played[0].soundId, 'rareSound', 'Rare sound must fire ahead of routine sound'); // Advance by 500ms (< 1500ms minGap): routine must still NOT fire cadence.update(1500); assert.equal(fakeAudio.played.length, 1, 'Routine sound must not fire before minGap'); // Advance to t = 2500ms (1000 + 1500 = 2500ms minGap elapsed): routine can now fire cadence.update(2500); assert.equal(fakeAudio.played.length, 2); assert.equal(fakeAudio.played[1].soundId, 'routineSound', 'Routine sound fires after minGap elapsed'); }); // --------------------------------------------------------------------------- // Trace 13: Ambient Voice Maintenance (Spec 20.15 Trace 13) // --------------------------------------------------------------------------- test('Trace 13: Continuous ambient sounds auto-start and maintain exactly one active voice', () => { const doc = baseExhibit({ sounds: { ambientHum: { name: 'Ambient Hum', usage: ['automatic'], cadence: { class: 'ambient' }, bus: 'ambient', recipe: { mode: 'continuous', nodes: { osc: { type: 'oscillator' } }, routes: [{ from: 'osc', to: 'output' }] } } } }); const rng = new SeededRNG(42); const fakeAudio = new FakeAudioSubsystem(); const cadence = new CadenceSubsystem({ document: doc, rng, audio: fakeAudio }); // Tick 1 at t = 0: auto-starts ambient sound cadence.update(0); assert.equal(fakeAudio.played.length, 1); assert.equal(fakeAudio.played[0].soundId, 'ambientHum'); // Tick 2 at t = 100: must NOT spawn a second duplicate voice cadence.update(100); assert.equal(fakeAudio.played.length, 1, 'Should maintain existing ambient voice without duplicating'); // If ambient voice is stopped externally, next update restarts it const ambientVoice = fakeAudio.played[0].voice; ambientVoice.stop(); cadence.update(200); assert.equal(fakeAudio.played.length, 2, 'Ambient voice should restart when previous instance stopped'); }); // --------------------------------------------------------------------------- // Trace 14: Manual SAMPLE PRNG Stream Isolation (Spec 20.15 Trace 14) // --------------------------------------------------------------------------- test('Trace 14: Manual SAMPLE evaluations run in isolated stream without perturbing cadence scheduling', () => { const doc = baseExhibit(); // Run 1: Normal cadence updates const rng1 = new SeededRNG(9999); const fakeAudio1 = new FakeAudioSubsystem(); const cadence1 = new CadenceSubsystem({ document: doc, rng: rng1, audio: fakeAudio1 }); for (let t = 0; t <= 100_000; t += 1000) { cadence1.update(t); } // Run 2: Same seed, but with intermediate manual-sample PRNG draws const rng2 = new SeededRNG(9999); const fakeAudio2 = new FakeAudioSubsystem(); const cadence2 = new CadenceSubsystem({ document: doc, rng: rng2, audio: fakeAudio2 }); for (let t = 0; t <= 100_000; t += 1000) { // Inject manual SAMPLE operations on isolated stream rng2.stream('manual-sample', 'button1').nextFloat(); rng2.stream('sample', 'button2').nextFloat(); cadence2.update(t); } // The sequence of fired sounds and timestamps must match exactly assert.equal(fakeAudio1.played.length, fakeAudio2.played.length, 'Playback counts must match'); for (let i = 0; i < fakeAudio1.played.length; i++) { assert.equal( fakeAudio1.played[i].soundId, fakeAudio2.played[i].soundId, `Sound ${i} ID mismatch` ); assert.equal( fakeAudio1.played[i].time, fakeAudio2.played[i].time, `Sound ${i} timestamp mismatch` ); } });