194 lines
5.1 KiB
JavaScript
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
|
|
}
|
|
}
|
|
})
|