import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BARS, CHORD_ROOTS, eventsAtStep, midiToFrequency, STEPS_PER_BAR, TOTAL_STEPS, } from '../../app/static/js/logic/marchPattern.js'; import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from '../../app/static/js/systems/AudioAssets.js'; import { AudioManager } from '../../app/static/js/systems/AudioManager.js'; import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js'; import { SYNTH_RECIPES } from '../../app/static/js/systems/Synth.js'; const ASSET_DIR = join(import.meta.dirname, '..', '..', 'app', 'static', 'assets', AUDIO_PATH); // ------------------------------------------------------------ minimaler AudioContext-Ersatz class FakeParam { constructor(value = 1) { this.value = value; } setValueAtTime(v) { this.value = v; return this; } exponentialRampToValueAtTime() { return this; } setTargetAtTime(v) { this.value = v; return this; } } class FakeNode { constructor(ctx) { this.ctx = ctx; } connect(node) { return node; } disconnect() {} } class FakeContext { constructor() { this.state = 'running'; this.currentTime = 0; this.sampleRate = 8000; this.destination = new FakeNode(this); this.created = { oscillator: 0, source: 0 }; this.startedBuffers = []; } createGain() { return Object.assign(new FakeNode(this), { gain: new FakeParam(1) }); } createOscillator() { this.created.oscillator += 1; return Object.assign(new FakeNode(this), { frequency: new FakeParam(440), type: 'sine', start() {}, stop() {} }); } createBufferSource() { this.created.source += 1; const ctx = this; return Object.assign(new FakeNode(this), { playbackRate: new FakeParam(1), buffer: null, start() { ctx.startedBuffers.push(this.buffer); }, stop() {}, }); } createBiquadFilter() { return Object.assign(new FakeNode(this), { frequency: new FakeParam(350), Q: new FakeParam(1), type: 'lowpass' }); } createBuffer(_channels, length) { const data = new Float32Array(length); return { getChannelData: () => data }; } decodeAudioData(bytes) { return Promise.resolve({ decodedFrom: bytes.byteLength }); } resume() { this.state = 'running'; return Promise.resolve(); } } function memorySettings() { const data = new Map(); return new Settings(new SafeStorage({ getItem: (k) => data.get(k) ?? null, setItem: (k, v) => data.set(k, v) })); } const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); // ------------------------------------------------------------------------------ Tests describe('AudioAssets', () => { it('verweist nur auf vorhandene Dateien', () => { const missing = allSoundFiles().filter((file) => !existsSync(join(ASSET_DIR, file))); expect(missing).toEqual([]); }); it('hat für jeden Effekt einen synthetischen Ersatz (Spiel bleibt ohne Dateien hörbar)', () => { const withoutRecipe = Object.keys(SOUND_FILES).filter((name) => typeof SYNTH_RECIPES[name] !== 'function'); expect(withoutRecipe).toEqual([]); }); it('hat für jeden Effekt Abspieloptionen mit gültiger Lautstärke', () => { for (const name of Object.keys(SOUND_FILES)) { const volume = SOUND_OPTIONS[name]?.volume ?? 1; expect(volume, name).toBeGreaterThan(0); expect(volume, name).toBeLessThanOrEqual(1); } }); }); describe('AudioManager', () => { beforeEach(() => { vi.stubGlobal('AudioContext', FakeContext); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); it('bleibt vor der ersten Nutzeraktion stumm, ohne Fehler', () => { const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() }); expect(() => audio.play('shot-carbine')).not.toThrow(); expect(audio.context).toBeNull(); }); it('lädt Dateien vorab und spielt sie nach dem Entsperren ab', async () => { const fetchImpl = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(16) })); const audio = new AudioManager(memorySettings(), { staticBase: '/glory/static/', fetchImpl }); audio.preload(); expect(fetchImpl).toHaveBeenCalledWith(`/glory/static/assets/${AUDIO_PATH}laserSmall_000.ogg`); expect(fetchImpl).toHaveBeenCalledTimes(allSoundFiles().length); audio.unlock(); await flush(); await flush(); audio.play('shot-carbine'); expect(audio.context.startedBuffers).toHaveLength(1); expect(audio.context.startedBuffers[0]).toEqual({ decodedFrom: 16 }); }); it('weicht auf den Synthesizer aus, wenn Dateien fehlen', async () => { const fetchImpl = vi.fn(async () => ({ ok: false })); const audio = new AudioManager(memorySettings(), { fetchImpl }); audio.preload(); audio.unlock(); await flush(); audio.play('explosion-big'); // Gespielt wurde nur Rauschen aus dem Synthesizer, keine dekodierte Datei. expect(audio.context.startedBuffers.filter((b) => b && 'decodedFrom' in b)).toHaveLength(0); expect(audio.context.created.oscillator).toBeGreaterThan(0); }); it('begrenzt schnelle Wiederholungen desselben Effekts', () => { const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() }); audio.unlock(); const before = audio.context.created.oscillator; audio.play('combo-step'); audio.play('combo-step'); expect(audio.context.created.oscillator - before).toBe(1); }); it('überträgt Lautstärkeänderungen sofort auf die Kanäle', () => { const settings = memorySettings(); const audio = new AudioManager(settings, { fetchImpl: vi.fn() }); audio.unlock(); settings.setVolume('music', 0.2); settings.setVolume('master', 0.6); expect(audio.gains.music.gain.value).toBe(0.2); expect(audio.gains.master.gain.value).toBe(0.6); }); it('startet vorgemerkte Musik erst nach dem Entsperren und stoppt sie wieder', () => { vi.useFakeTimers(); const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() }); audio.startMusic(); expect(audio.music).toBeNull(); audio.unlock(); expect(audio.music).not.toBeNull(); vi.advanceTimersByTime(200); expect(audio.context.created.oscillator).toBeGreaterThan(0); audio.stopMusic(); expect(audio.music).toBeNull(); expect(audio.musicWanted).toBe(false); }); it('senkt die Musik in der Pause ab', () => { const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() }); audio.unlock(); audio.duck(true); expect(audio.gains.duck.gain.value).toBeLessThan(1); audio.duck(false); expect(audio.gains.duck.gain.value).toBe(1); }); }); describe('Marschmuster', () => { const all = Array.from({ length: TOTAL_STEPS }, (_, step) => eventsAtStep(step)); const count = (instrument) => all.flat().filter((e) => e.instrument === instrument).length; it('hat 8 Takte à 16 Schritte', () => { expect(TOTAL_STEPS).toBe(BARS * STEPS_PER_BAR); expect(CHORD_ROOTS).toHaveLength(BARS); }); it('schlägt die große Trommel auf 1 und 3 jedes Takts', () => { expect(count('kick')).toBe(BARS * 2); for (let bar = 0; bar < BARS; bar += 1) { expect(all[bar * STEPS_PER_BAR].some((e) => e.instrument === 'kick')).toBe(true); expect(all[bar * STEPS_PER_BAR + 8].some((e) => e.instrument === 'kick')).toBe(true); } }); it('spielt die Melodie nur mit Tönen aus C-Dur', () => { const cMajor = new Set([0, 2, 4, 5, 7, 9, 11]); const melody = all.flat().filter((e) => e.instrument === 'lead'); expect(melody.length).toBeGreaterThan(20); for (const event of melody) expect(cMajor.has(event.notes[0] % 12)).toBe(true); }); it('wiederholt sich nahtlos', () => { expect(eventsAtStep(TOTAL_STEPS)).toEqual(eventsAtStep(0)); expect(eventsAtStep(-1)).toEqual(eventsAtStep(TOTAL_STEPS - 1)); }); it('rechnet MIDI-Töne in Frequenzen um', () => { expect(midiToFrequency(69)).toBeCloseTo(440); expect(midiToFrequency(57)).toBeCloseTo(220); expect(midiToFrequency(60)).toBeCloseTo(261.63, 1); }); });