chore: complete clean Agent Desk source snapshot
This commit is contained in:
193
web/lib/sound-alert.test.mjs
Normal file
193
web/lib/sound-alert.test.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
playIncomingMessageSound,
|
||||
resetAudioContextForTesting,
|
||||
} from "./sound-alert.ts"
|
||||
|
||||
class MockAudioParam {
|
||||
constructor(defaultValue = 0) {
|
||||
this.value = defaultValue
|
||||
this.calls = []
|
||||
}
|
||||
setValueAtTime(val, time) {
|
||||
this.calls.push({ method: "setValueAtTime", val, time })
|
||||
}
|
||||
exponentialRampToValueAtTime(val, time) {
|
||||
this.calls.push({ method: "exponentialRampToValueAtTime", val, time })
|
||||
}
|
||||
}
|
||||
|
||||
class MockAudioNode {
|
||||
constructor() {
|
||||
this.connectedTo = null
|
||||
}
|
||||
connect(target) {
|
||||
this.connectedTo = target
|
||||
}
|
||||
}
|
||||
|
||||
class MockOscillatorNode extends MockAudioNode {
|
||||
constructor() {
|
||||
super()
|
||||
this.type = "sine"
|
||||
this.frequency = new MockAudioParam(440)
|
||||
this.startCalls = []
|
||||
this.stopCalls = []
|
||||
}
|
||||
start(time) {
|
||||
this.startCalls.push(time)
|
||||
}
|
||||
stop(time) {
|
||||
this.stopCalls.push(time)
|
||||
}
|
||||
}
|
||||
|
||||
class MockGainNode extends MockAudioNode {
|
||||
constructor() {
|
||||
super()
|
||||
this.gain = new MockAudioParam(1)
|
||||
}
|
||||
}
|
||||
|
||||
class MockAudioContext {
|
||||
constructor() {
|
||||
this.state = "running"
|
||||
this.currentTime = 5.0
|
||||
this.destination = new MockAudioNode()
|
||||
this.oscillators = []
|
||||
this.gains = []
|
||||
this.resumeCalled = false
|
||||
this.resumePromise = Promise.resolve()
|
||||
}
|
||||
createOscillator() {
|
||||
const osc = new MockOscillatorNode()
|
||||
this.oscillators.push(osc)
|
||||
return osc
|
||||
}
|
||||
createGain() {
|
||||
const gain = new MockGainNode()
|
||||
this.gains.push(gain)
|
||||
return gain
|
||||
}
|
||||
resume() {
|
||||
this.resumeCalled = true
|
||||
return this.resumePromise
|
||||
}
|
||||
}
|
||||
|
||||
test("returns false safely in Node.js / SSR environment without window", () => {
|
||||
const originalWindow = globalThis.window
|
||||
try {
|
||||
delete globalThis.window
|
||||
resetAudioContextForTesting()
|
||||
const result = playIncomingMessageSound()
|
||||
assert.equal(result, false)
|
||||
} finally {
|
||||
if (originalWindow !== undefined) {
|
||||
globalThis.window = originalWindow
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("returns false safely when window exists but AudioContext is unsupported", () => {
|
||||
const originalWindow = globalThis.window
|
||||
try {
|
||||
globalThis.window = {}
|
||||
resetAudioContextForTesting()
|
||||
const result = playIncomingMessageSound()
|
||||
assert.equal(result, false)
|
||||
} finally {
|
||||
if (originalWindow !== undefined) {
|
||||
globalThis.window = originalWindow
|
||||
} else {
|
||||
delete globalThis.window
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("synthesizes dual-tone chime (587.33Hz and 880.00Hz) with gain ramps", () => {
|
||||
const originalWindow = globalThis.window
|
||||
try {
|
||||
let mockCtx = null
|
||||
globalThis.window = {
|
||||
AudioContext: class {
|
||||
constructor() {
|
||||
mockCtx = new MockAudioContext()
|
||||
return mockCtx
|
||||
}
|
||||
},
|
||||
}
|
||||
resetAudioContextForTesting()
|
||||
|
||||
const result = playIncomingMessageSound()
|
||||
assert.equal(result, true)
|
||||
assert.ok(mockCtx, "AudioContext should be created")
|
||||
|
||||
// Verify 2 oscillators created
|
||||
assert.equal(mockCtx.oscillators.length, 2)
|
||||
const [osc1, osc2] = mockCtx.oscillators
|
||||
|
||||
// Note 1: 587.33 Hz (D5) for 0.08s
|
||||
assert.equal(osc1.type, "sine")
|
||||
assert.equal(osc1.frequency.calls[0].val, 587.33)
|
||||
assert.equal(osc1.frequency.calls[0].time, 5.0)
|
||||
assert.equal(osc1.startCalls[0], 5.0)
|
||||
assert.equal(osc1.stopCalls[0], 5.08)
|
||||
|
||||
// Note 2: 880.00 Hz (A5) for 0.16s
|
||||
assert.equal(osc2.type, "sine")
|
||||
assert.equal(osc2.frequency.calls[0].val, 880.00)
|
||||
assert.equal(osc2.frequency.calls[0].time, 5.08)
|
||||
assert.equal(osc2.startCalls[0], 5.08)
|
||||
assert.equal(osc2.stopCalls[0], 5.08 + 0.16)
|
||||
|
||||
// Verify 2 gains with exponentialRampToValueAtTime
|
||||
assert.equal(mockCtx.gains.length, 2)
|
||||
const [gain1, gain2] = mockCtx.gains
|
||||
const ramp1 = gain1.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
|
||||
const ramp2 = gain2.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
|
||||
assert.ok(ramp1, "gain1 should have exponential ramp")
|
||||
assert.ok(ramp2, "gain2 should have exponential ramp")
|
||||
assert.equal(ramp1.time, 5.08)
|
||||
assert.equal(ramp2.time, 5.24)
|
||||
} finally {
|
||||
if (originalWindow !== undefined) {
|
||||
globalThis.window = originalWindow
|
||||
} else {
|
||||
delete globalThis.window
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("handles suspended AudioContext and catches rejected resume() safely", async () => {
|
||||
const originalWindow = globalThis.window
|
||||
try {
|
||||
let mockCtx = null
|
||||
globalThis.window = {
|
||||
AudioContext: class {
|
||||
constructor() {
|
||||
mockCtx = new MockAudioContext()
|
||||
mockCtx.state = "suspended"
|
||||
mockCtx.resumePromise = Promise.reject(new Error("Autoplay not allowed"))
|
||||
return mockCtx
|
||||
}
|
||||
},
|
||||
}
|
||||
resetAudioContextForTesting()
|
||||
|
||||
// Should not throw or crash on autoplay rejection
|
||||
const result = playIncomingMessageSound()
|
||||
assert.equal(result, true)
|
||||
assert.equal(mockCtx.resumeCalled, true)
|
||||
|
||||
// Wait a tick for promise rejection to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
} finally {
|
||||
if (originalWindow !== undefined) {
|
||||
globalThis.window = originalWindow
|
||||
} else {
|
||||
delete globalThis.window
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user