const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const { createAudioEngine, MAX_VOICES, MAX_GAIN } = require('../../data/web_audio.js'); function memoryStorage() { const values = new Map(); return { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, String(value)) }; } function fakeTimer() { let next = 1; const tasks = new Map(); return { setTimeout: callback => { const id = next++; tasks.set(id, callback); return id; }, clearTimeout: id => tasks.delete(id), runAll: () => [...tasks.values()].forEach(callback => callback()), size: () => tasks.size }; } function fakeAudioContext() { const contexts = []; const parameter = () => ({ setValueAtTime() {}, exponentialRampToValueAtTime() {} }); const node = () => ({ connected: 0, disconnected: 0, connect() { this.connected += 1; }, disconnect() { this.disconnected += 1; } }); class FakeAudioContext { constructor() { this.currentTime = 0; this.sampleRate = 1000; this.destination = node(); this.state = 'suspended'; contexts.push(this); } createGain() { return { ...node(), gain: parameter() }; } createDynamicsCompressor() { return { ...node(), threshold: parameter(), knee: parameter(), ratio: parameter() }; } createBiquadFilter() { return { ...node(), frequency: parameter(), type: '' }; } createOscillator() { return { ...node(), frequency: parameter(), start() {}, stop() { this.stopped = true; } }; } createBufferSource() { return { ...node(), start() {}, stop() { this.stopped = true; } }; } createBuffer(_channels, length) { const samples = new Float32Array(length); return { getChannelData: () => samples }; } async resume() { this.state = 'running'; } async suspend() { this.state = 'suspended'; } close() { this.state = 'closed'; } } return { FakeAudioContext, contexts }; } test('does not create an audio context before explicit enablement', () => { const { FakeAudioContext, contexts } = fakeAudioContext(); const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage() }); assert.equal(contexts.length, 0); assert.equal(engine.play('test'), false); assert.equal(contexts.length, 0); }); test('enables through a user gesture, persists safe preferences, and caps gain', async () => { const { FakeAudioContext, contexts } = fakeAudioContext(); const storage = memoryStorage(); const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage }); assert.equal(await engine.enable(), true); engine.setVolume('loud'); engine.setReduced(true); assert.equal(contexts.length, 1); assert.equal(engine.getPreferences().enabled, true); assert.equal(engine.getPreferences().volume, 'loud'); assert.equal(engine.getPreferences().reduced, true); assert.equal(engine.getMaxGain(), MAX_GAIN); assert.ok(MAX_GAIN <= 0.3); assert.equal(storage.getItem('battleship.soundVolume'), 'loud'); }); test('uses a bounded voice pool, priority replacement, one noise buffer, and cleanup', async () => { const { FakeAudioContext } = fakeAudioContext(); const timer = fakeTimer(); const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer }); await engine.enable(); for (let index = 0; index < MAX_VOICES; index += 1) assert.equal(engine.play('test'), true); assert.equal(engine.getVoiceCount(), MAX_VOICES); assert.equal(engine.play('test'), true); assert.equal(engine.getVoiceCount(), MAX_VOICES); assert.equal(engine.play('noise'), true); const noise = engine.getNoiseBuffer(); assert.ok(noise); engine.play('noise'); assert.equal(engine.getNoiseBuffer(), noise); timer.runAll(); assert.equal(engine.getVoiceCount(), 0); assert.equal(timer.size(), 0); }); test('suppresses duplicate versions and cancels audio for mute, page hide, and reset cleanup', async () => { const { FakeAudioContext, contexts } = fakeAudioContext(); const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer: fakeTimer() }); await engine.enable(); assert.equal(engine.play('test', 4), true); assert.equal(engine.play('test', 4), false); assert.equal(engine.getVoiceCount(), 1); await engine.setPageHidden(true); assert.equal(engine.getVoiceCount(), 0); await engine.setPageHidden(false); await engine.setEnabled(false); assert.equal(engine.play('test'), false); engine.cleanup(); assert.equal(contexts[0].state, 'closed'); }); test('falls back silently when Web Audio is unsupported or initialization fails', async () => { const silent = createAudioEngine({ AudioContext: undefined, storage: memoryStorage() }); assert.equal(await silent.enable(), false); assert.equal(silent.play('test'), false); class FailingContext { constructor() { throw new Error('no audio'); } } const failing = createAudioEngine({ AudioContext: FailingContext, storage: memoryStorage() }); assert.equal(await failing.enable(), false); assert.equal(failing.play('test'), false); }); test('the application exposes an explicit Russian sound control and cleans audio without changing transport', () => { const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8'); const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8'); const firmware = fs.readFileSync(path.join(__dirname, '../../src/main.c'), 'utf8'); assert.match(page, /id="sound-toggle"[^>]*aria-pressed="false"/); assert.match(page, /id="sound-volume"/); assert.match(page, /id="sound-reduced"/); assert.match(page, /id="recovery-sound-mute"/); assert.match(page, /src="\/web_audio\.js"/); assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/); assert.match(app, /audio\.cleanup\(\)/); assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/); assert.match(app, /visibilitychange/); assert.match(app, /soundKeys = new Set/); assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/); assert.match(app, /\/api\/game\/shot/); });