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

213 lines
6.7 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AudioManager, SoundManager, sharedAudioManager } from '../src/audio/manager.ts'
import { playStashOpenSound } from '../src/scene/frontend/mouse-controller.ts'
import { playInventoryFullFeedback } from '../src/scene/frontend/overhead-labels.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 50 sound and feedback triggers', () => {
const managerA = new AudioManager()
const managerB = new SoundManager()
for (let i = 0; i < 20; i++) {
playInventoryFullFeedback()
playStashOpenSound(managerA)
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'
playInventoryFullFeedback()
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')
playInventoryFullFeedback()
// playInventoryFullFeedback plays two tones: delayS = 0 and 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 overhead-labels.ts and mouse-controller.ts', () => {
const overheadSrc = readFileSync(
resolve(process.cwd(), 'src/scene/frontend/overhead-labels.ts'),
'utf8',
)
const mouseSrc = readFileSync(
resolve(process.cwd(), 'src/scene/frontend/mouse-controller.ts'),
'utf8',
)
expect(overheadSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/)
expect(overheadSrc).not.toContain('I cannot carry anymore')
expect(mouseSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/)
})
})