125 lines
6.2 KiB
JavaScript
125 lines
6.2 KiB
JavaScript
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(page, /src="\/game_sounds\.js"/);
|
|
assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/);
|
|
assert.match(firmware, /strcmp\(request->uri, "\/game_sounds\.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/);
|
|
assert.match(app, /sounds\.play\('shot'/);
|
|
});
|