import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { createRandomStream, deriveStreamState, FixedStepClock, GC4_CONSTANTS, planAudioUnlock } from '../tools/gc4-time-model.mjs'; function simulateFrames(frameDurationMs, durationMs) { const clock = new FixedStepClock(); const decisions = []; clock.advance(0); for (let now = frameDurationMs; now <= durationMs + 1e-7; now += frameDurationMs) { for (const tick of clock.advance(Math.min(now, durationMs)).ticks) { if (tick % 10 === 0) decisions.push(tick); } } return { tickIndex: clock.tickIndex, decisions }; } test('render frequency does not change fixed-tick decisions', () => { const at60 = simulateFrames(1000 / 60, 1000); const at120 = simulateFrames(1000 / 120, 1000); assert.deepEqual(at60, at120); assert.equal(at60.tickIndex, 60); assert.deepEqual(at60.decisions, [10, 20, 30, 40, 50, 60]); }); test('long stalls have bounded work and do not replay discarded wall time', () => { const clock = new FixedStepClock(); clock.advance(0); const stalled = clock.advance(1000); assert.equal(stalled.stalled, true); assert.equal(stalled.ticks.length, GC4_CONSTANTS.maxTicksPerTurn); assert.ok(clock.accumulatorMs < GC4_CONSTANTS.stepMs); }); test('visibility restoration does not clear explicit pause', () => { const clock = new FixedStepClock(); clock.advance(0); clock.advance(GC4_CONSTANTS.stepMs); assert.equal(clock.tickIndex, 1); clock.setPauseReason('explicit', true, 20); clock.setPauseReason('hidden', true, 20); clock.advance(10_000); clock.setPauseReason('hidden', false, 10_000); clock.advance(11_000); assert.equal(clock.tickIndex, 1); clock.setPauseReason('explicit', false, 11_000); clock.advance(11_000 + GC4_CONSTANTS.stepMs); assert.equal(clock.tickIndex, 2); }); test('unlock skips pre-unlock one-shots and aligns a live continuous sound', () => { const plan = planAudioUnlock([ { id: 'expired', kind: 'one-shot', start: 1, end: 1.2 }, { id: 'partial', kind: 'one-shot', start: 4.9, end: 5.2 }, { id: 'ambience', kind: 'continuous', start: 2 }, { id: 'future', kind: 'one-shot', start: 5.05, end: 5.2 } ], 5); assert.deepEqual(plan, [ { id: 'expired', action: 'skip' }, { id: 'partial', action: 'skip' }, { id: 'ambience', action: 'start-continuous', phase: 3, rampMs: 20 }, { id: 'future', action: 'schedule', delay: 0.04999999999999982 } ]); }); test('PRNG state and output are stable known vectors', () => { assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [ 471422921, 3327820418, 120898024, 15893603 ]); const stream = createRandomStream(42, 'scenario', 'storm:1'); assert.deepEqual([ stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32() ], [4101533927, 4149236977, 2113172072, 1672809915]); }); test('manual sampling does not perturb cadence or scenario streams', () => { const cadenceA = createRandomStream(7, 'cadence', 'routine:1'); const cadenceB = createRandomStream(7, 'cadence', 'routine:1'); const scenarioA = createRandomStream(7, 'scenario', 'surge:1'); const scenarioB = createRandomStream(7, 'scenario', 'surge:1'); const manual = createRandomStream(7, 'manual-sample', 'warning:1'); const before = [cadenceA.nextUint32(), scenarioA.nextUint32()]; for (let index = 0; index < 100; index++) manual.nextUint32(); const after = [cadenceA.nextUint32(), scenarioA.nextUint32()]; assert.deepEqual(before, [cadenceB.nextUint32(), scenarioB.nextUint32()]); assert.deepEqual(after, [cadenceB.nextUint32(), scenarioB.nextUint32()]); }); test('audio-reactive decisions use the same deterministic signal fixture at every render rate', () => { const signals = JSON.parse(readFileSync(new URL('./fixtures/gc4/deterministic-signals.json', import.meta.url))); const decide = () => signals.filter((signal) => signal.energy >= 0.5).map((signal) => signal.tick); assert.deepEqual(decide(), [2, 3]); assert.deepEqual(decide(), decide()); });