Files
agent-desk-ng/web/lib/sound-alert.test.mjs

194 lines
5.1 KiB
JavaScript

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
}
}
})