213 lines
6.7 KiB
TypeScript
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/)
|
|
})
|
|
})
|