Section 20 in full: the automatic scheduling algorithm with its eligible-pool filtering, cooldown and overlap exclusion, weight evaluation, the anti-repetition multiplier table applied by recency-queue position, pool relaxation when total effective weight reaches zero, and reschedule by uniform sample divided by intensity; the priority clocks and minimum-gap servicing of 20.6 with deferred classes retained; intensity clamping and the pause threshold of 20.7, under which ambient voices keep running; the 1024-unit dispatch budget and sixteen levels of event nesting; and manual SAMPLE evaluation in an isolated stream that does not perturb cadence scheduling. Two defects found by the section 20 review triage are fixed here rather than shipped. A sound action's usage check tested whether the target sound permitted *some* action context instead of the caller's own, so a scenario-only sound was playable from a manual action; it now checks the caller's context and rejects with ERR_UNSUPPORTED_TARGET, the code 20.2.2 and 20.12 actually name, and the two cases the previous test never exercised — manual-only from scenario, and scenario-only from manual — are covered. Traces 7 through 10 drove `triggerSound()` and `calculateEligiblePool()`, a duplicate scheduler with no production call site, rather than the `advance()`/`fireClass()` path a real exhibit runs. The two implementations disagreed on recency-history semantics: 20.5.9 requires a plain queue of the last four firings, and the duplicate moved an existing entry to the front instead. Nothing caught it because nothing called both. Those traces now drive `update()`/`advance()` with a seeded RNG, as traces 11 through 14 already did, and the duplicate is deleted rather than reconciled. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
870 lines
30 KiB
JavaScript
870 lines
30 KiB
JavaScript
// 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`
|
|
);
|
|
}
|
|
});
|