diablo2-web/tests/p0-528-audio-singleton.test.ts

202 lines
6.6 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AudioManager, SoundManager, sharedAudioManager } from '../src/client/audio/manager.ts'
interface MockOscillatorNode {
type: string
frequency: {
value: number
setValueAtTime: ReturnType<typeof vi.fn>
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
}
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
addEventListener: ReturnType<typeof vi.fn>
onended: (() => void) | null
triggerEnded: () => void
}
interface MockGainNode {
gain: {
value: number
setValueAtTime: ReturnType<typeof vi.fn>
linearRampToValueAtTime: ReturnType<typeof vi.fn>
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
}
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
}
describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => {
let ctorCount = 0
let resumeCalls = 0
let closeCalls = 0
let createdOscillators: MockOscillatorNode[] = []
let createdGains: MockGainNode[] = []
let mockState: 'running' | 'suspended' | 'closed' = 'running'
beforeEach(() => {
ctorCount = 0
resumeCalls = 0
closeCalls = 0
createdOscillators = []
createdGains = []
mockState = 'running'
AudioManager.resetSharedContextForTesting()
class MockAudioContext {
currentTime = 10.0
sampleRate = 22050
destination = {}
get state() {
return mockState
}
constructor() {
ctorCount += 1
}
resume = vi.fn(async () => {
resumeCalls += 1
mockState = 'running'
})
close = vi.fn(async () => {
closeCalls += 1
mockState = 'closed'
})
createOscillator = vi.fn((): MockOscillatorNode => {
const listeners: Array<() => void> = []
const osc: MockOscillatorNode = {
type: 'sine',
frequency: {
value: 440,
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
disconnect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === 'ended') listeners.push(cb)
}),
onended: null,
triggerEnded: () => {
for (const cb of listeners) cb()
if (osc.onended) osc.onended()
},
}
createdOscillators.push(osc)
return osc
})
createGain = vi.fn((): MockGainNode => {
const gain: MockGainNode = {
gain: {
value: 1,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
disconnect: vi.fn(),
}
createdGains.push(gain)
return gain
})
createBuffer = vi.fn((_channels: number, length: number, sampleRate: number) => ({
length,
sampleRate,
getChannelData: () => new Float32Array(length),
}))
createBufferSource = vi.fn(() => {
const listeners: Array<() => void> = []
return {
buffer: null,
connect: vi.fn(),
disconnect: vi.fn(),
start: vi.fn(),
addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === 'ended') listeners.push(cb)
}),
onended: null,
}
})
}
vi.stubGlobal('AudioContext', MockAudioContext)
vi.stubGlobal('window', { AudioContext: MockAudioContext })
})
afterEach(() => {
AudioManager.resetSharedContextForTesting()
vi.unstubAllGlobals()
})
it('constructs at most 1 AudioContext across 60 sound triggers from independent managers', () => {
const managerA = new AudioManager()
const managerB = new SoundManager()
for (let i = 0; i < 20; i++) {
managerA.playItemDropSound('item_ring_1')
managerB.playSound('item_gold_1')
SoundManager.playTone({ type: 'sine', from: 220, to: 180, durationS: 0.1, gain: 0.1 })
}
expect(ctorCount).toBe(1)
})
it('automatically calls resume() when the shared AudioContext is suspended', () => {
mockState = 'suspended'
sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 })
expect(ctorCount).toBe(1)
expect(resumeCalls).toBeGreaterThanOrEqual(1)
expect(mockState).toBe('running')
})
it('schedules delayed tones on ctx.currentTime without setTimeout and disconnects nodes on ended', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 })
sharedAudioManager.playTone({ type: 'triangle', from: 110, to: 45, gain: 0.2, durationS: 0.1, delayS: 0.35 })
expect(setTimeoutSpy).not.toHaveBeenCalled()
expect(createdOscillators.length).toBe(2)
const firstOsc = createdOscillators[0]!
const secondOsc = createdOscillators[1]!
expect(firstOsc.start).toHaveBeenCalledWith(10.0)
expect(secondOsc.start).toHaveBeenCalledWith(10.35)
// Trigger ended on both oscillators and verify disconnect() cleanup
const toneGains = createdGains.slice(-2)
firstOsc.triggerEnded()
secondOsc.triggerEnded()
expect(firstOsc.disconnect).toHaveBeenCalled()
expect(secondOsc.disconnect).toHaveBeenCalled()
expect(toneGains[0]!.disconnect).toHaveBeenCalled()
expect(toneGains[1]!.disconnect).toHaveBeenCalled()
setTimeoutSpy.mockRestore()
})
it('closes the shared AudioContext on dispose() and recreates cleanly if used again', () => {
sharedAudioManager.playItemDropSound('item_potion_1')
expect(ctorCount).toBe(1)
sharedAudioManager.dispose()
expect(closeCalls).toBe(1)
// Subsequent sound after dispose creates a fresh context
sharedAudioManager.playItemDropSound('item_potion_1')
expect(ctorCount).toBe(2)
})
it('has zero raw new AudioContext instantiations in src/client outside audio/manager.ts', () => {
const root = resolve(process.cwd(), 'src/client')
const offenders = (readdirSync(root, { recursive: true }) as string[])
.filter(p => p.endsWith('.ts') && p.split(sep).join('/') !== 'audio/manager.ts')
.filter(p => /new\s+(AudioContext|AudioCtx)\b|webkitAudioContext/.test(readFileSync(join(root, p), 'utf8')))
expect(offenders).toEqual([])
})
})