feat: Add support for inbound "calls" from triggers (#2500)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent f4c686c298
commit 15e335a647
39 changed files with 2915 additions and 133 deletions
@@ -14,7 +14,10 @@ it('should handle call_start action without a camera or stream', async () => {
await action.execute(api);
expect(api.getCallManager().start).toBeCalledWith(undefined, undefined);
expect(api.getCallManager().start).toBeCalledWith({
cameraID: undefined,
streamID: undefined,
});
});
it('should handle call_start action with a camera and stream', async () => {
@@ -31,8 +34,8 @@ it('should handle call_start action with a camera and stream', async () => {
await action.execute(api);
expect(api.getCallManager().start).toBeCalledWith(
'camera.front',
'camera.front_doorbell',
);
expect(api.getCallManager().start).toBeCalledWith({
cameraID: 'camera.front',
streamID: 'camera.front_doorbell',
});
});
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Ringtone } from '../../../src/card-controller/call/ringtone';
import { ArpeggioTone } from '../../../src/card-controller/call/tones/arpeggio';
import { ChimeTone } from '../../../src/card-controller/call/tones/chime';
import { CustomTone } from '../../../src/card-controller/call/tones/custom';
import { MelodyTone } from '../../../src/card-controller/call/tones/melody';
import { WestminsterTone } from '../../../src/card-controller/call/tones/westminster';
import { RingtoneConfig } from '../../../src/config/schema/live';
// Each tone constructor returns a fresh `mock<>()` per `new` call; the mock
// implementation persists across `vi.clearAllMocks()` (which only clears call
// records, not implementations) so tests don't need per-test re-installation.
vi.mock('../../../src/card-controller/call/tones/chime', () => ({
ChimeTone: vi.fn().mockImplementation(() => mock<ChimeTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/westminster', () => ({
WestminsterTone: vi.fn().mockImplementation(() => mock<WestminsterTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/arpeggio', () => ({
ArpeggioTone: vi.fn().mockImplementation(() => mock<ArpeggioTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/melody', () => ({
MelodyTone: vi.fn().mockImplementation(() => mock<MelodyTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/custom', () => ({
CustomTone: vi.fn().mockImplementation(() => mock<CustomTone>()),
}));
// Returns the most recently constructed instance of a mocked class.
const lastInstance = <T>(ctor: { mock: { results: { value: T }[] } }): T => {
const result = ctor.mock.results.at(-1);
if (!result) {
throw new Error('No mocked instance has been constructed yet');
}
return result.value;
};
const chimeConfig: RingtoneConfig = { type: 'chime', repeat: 0 };
beforeEach(() => {
vi.clearAllMocks();
});
describe('factory dispatch', () => {
it('should construct a ChimeTone for type "chime"', () => {
new Ringtone(new Set()).start({ type: 'chime', repeat: 3 });
expect(ChimeTone).toBeCalledWith(3);
});
it('should construct a WestminsterTone for type "westminster"', () => {
new Ringtone(new Set()).start({ type: 'westminster', repeat: 2 });
expect(WestminsterTone).toBeCalledWith(2);
});
it('should construct an ArpeggioTone for type "arpeggio"', () => {
new Ringtone(new Set()).start({ type: 'arpeggio', repeat: 1 });
expect(ArpeggioTone).toBeCalledWith(1);
});
it('should construct a MelodyTone for type "melody"', () => {
new Ringtone(new Set()).start({ type: 'melody', repeat: 5 });
expect(MelodyTone).toBeCalledWith(5);
});
it('should construct a CustomTone for type "custom" with a URL', () => {
new Ringtone(new Set()).start({
type: 'custom',
url: 'http://localhost/ring.mp3',
repeat: 0,
});
expect(CustomTone).toBeCalledWith('http://localhost/ring.mp3', 0);
});
it('should construct no tone for type "custom" without a URL', () => {
const ringtone = new Ringtone(new Set());
ringtone.start({ type: 'custom', repeat: 0 });
expect(CustomTone).not.toBeCalled();
expect(ringtone.isPlaying()).toBe(false);
});
it('should construct no tone for type "none"', () => {
const ringtone = new Ringtone(new Set());
ringtone.start({ type: 'none', repeat: 0 });
expect(ChimeTone).not.toBeCalled();
expect(ringtone.isPlaying()).toBe(false);
});
});
describe('start', () => {
it('should start the tone and report playing', () => {
const ringtone = new Ringtone(new Set());
ringtone.start(chimeConfig);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalled();
expect(ringtone.isPlaying()).toBe(true);
});
it('should no-op when already playing', () => {
const ringtone = new Ringtone(new Set());
ringtone.start(chimeConfig);
ringtone.start(chimeConfig);
expect(ChimeTone).toBeCalledTimes(1);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalledTimes(1);
});
it('should claim the lock when a tone starts', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
expect(lock.has(ringtone)).toBe(true);
});
it('should not claim the lock when no tone is created', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start({ type: 'none', repeat: 0 });
expect(lock.size).toBe(0);
});
});
describe('lock', () => {
it('should refuse to start when another ringtone holds the lock', () => {
const lock = new Set<Ringtone>();
const first = new Ringtone(lock);
const second = new Ringtone(lock);
first.start(chimeConfig);
vi.mocked(ChimeTone).mockClear();
second.start(chimeConfig);
expect(ChimeTone).not.toBeCalled();
expect(second.isPlaying()).toBe(false);
expect(first.isPlaying()).toBe(true);
});
it('should release the lock on stop so a peer can start', () => {
const lock = new Set<Ringtone>();
const first = new Ringtone(lock);
const second = new Ringtone(lock);
first.start(chimeConfig);
const firstTone = lastInstance(vi.mocked(ChimeTone));
first.stop();
expect(firstTone.stop).toBeCalled();
second.start(chimeConfig);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalled();
expect(second.isPlaying()).toBe(true);
});
it('should sweep stale holders whose tone never released the lock', () => {
const lock = new Set<Ringtone>();
const stale = new Ringtone(lock);
// Simulate a stale entry: a holder that says it's no longer playing but
// is still in the lock set (e.g. controller GC'd without disconnect).
lock.add(stale);
expect(stale.isPlaying()).toBe(false);
const fresh = new Ringtone(lock);
fresh.start(chimeConfig);
expect(lock.has(stale)).toBe(false);
expect(fresh.isPlaying()).toBe(true);
});
});
describe('natural finish', () => {
it('should release the lock when the tone fires its finished handler', () => {
let finishedHandler: (() => void) | undefined;
vi.mocked(ChimeTone).mockImplementationOnce(() => {
const tone = mock<ChimeTone>();
vi.mocked(tone.start).mockImplementation((handler) => {
finishedHandler = handler;
});
return tone;
});
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
expect(lock.has(ringtone)).toBe(true);
finishedHandler?.();
expect(lock.has(ringtone)).toBe(false);
expect(ringtone.isPlaying()).toBe(false);
});
});
describe('stop', () => {
it('should release the lock and stop the tone', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
ringtone.stop();
expect(lastInstance(vi.mocked(ChimeTone)).stop).toBeCalled();
expect(lock.has(ringtone)).toBe(false);
expect(ringtone.isPlaying()).toBe(false);
});
it('should be safe when called without a prior start', () => {
const ringtone = new Ringtone(new Set());
expect(() => ringtone.stop()).not.toThrow();
});
});
describe('default lock', () => {
it('should default to the module-level lock when no lock is provided', () => {
// Two ringtones constructed with no args share the module-level lock, so
// the second must refuse to play while the first holds it.
const first = new Ringtone();
const second = new Ringtone();
first.start(chimeConfig);
vi.mocked(ChimeTone).mockClear();
second.start(chimeConfig);
expect(ChimeTone).not.toBeCalled();
first.stop();
});
});
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { ArpeggioTone } from '../../../../src/card-controller/call/tones/arpeggio';
import { useAudioMocks } from './test-utils';
// Each strike emits a 3-layer bell stack in order: sparkle (octave above
// fundamental), fundamental, hum (octave below). Indices into
// `audio.oscillators` step through the three descending plucks.
describe('ArpeggioTone', () => {
const audio = useAudioMocks();
it('should play G5-E5-C5 descending plucks 0.25s apart', () => {
new ArpeggioTone(0).start();
// 3 strikes × 3 layers = 9 oscillators.
expect(audio.oscillators).toHaveLength(9);
// G5 (783.99) at t=0.
expect(audio.oscillators[1].frequency.value).toBe(783.99);
expect(audio.oscillators[1].start).toBeCalledWith(0);
// E5 (659.25) at t=0.25.
expect(audio.oscillators[4].frequency.value).toBe(659.25);
expect(audio.oscillators[4].start).toBeCalledWith(0.25);
// C5 (523.25) at t=0.5.
expect(audio.oscillators[7].frequency.value).toBe(523.25);
expect(audio.oscillators[7].start).toBeCalledWith(0.5);
});
it('should use the lighter PLUCK envelope for every strike', () => {
new ArpeggioTone(0).start();
// Sparkle / fundamental / hum peaks for every strike.
for (let strike = 0; strike < 3; strike++) {
const i = strike * 3;
expect(audio.gainParams[i].linearRampToValueAtTime).toBeCalledWith(
0.05,
expect.any(Number),
);
expect(audio.gainParams[i + 1].linearRampToValueAtTime).toBeCalledWith(
0.13,
expect.any(Number),
);
expect(audio.gainParams[i + 2].linearRampToValueAtTime).toBeCalledWith(
0.04,
expect.any(Number),
);
}
});
});
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChimeTone } from '../../../../src/card-controller/call/tones/chime';
import { useAudioMocks } from './test-utils';
// `GeneratedTone` is abstract; its shared machinery (AudioContext lifecycle,
// repeat-counter scheduling, stop-suppresses-finishedHandler) is exercised
// through a real concrete subclass. `ChimeTone` is the chosen vehicle: it
// produces a deterministic 6-oscillator iteration (2 strikes × 3 bell-stack
// layers) and loops every 5 seconds, giving stable counts to assert on.
const ITERATION_OSCILLATORS = 6;
const ITERATION_INTERVAL_MS = 5_000;
const audio = useAudioMocks();
// @vitest-environment jsdom
describe('start', () => {
it('should construct an AudioContext and play one iteration', () => {
new ChimeTone(0).start();
expect(audio.audioContextCtor).toBeCalledTimes(1);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
});
it('should no-op when called twice without stop', () => {
const tone = new ChimeTone(0);
tone.start();
tone.start();
expect(audio.audioContextCtor).toBeCalledTimes(1);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
});
it('should fire finishedHandler when AudioContext construction throws', () => {
audio.audioContextCtor.mockImplementation(() => {
throw new Error('unsupported');
});
const onFinished = vi.fn();
new ChimeTone(0).start(onFinished);
expect(onFinished).toBeCalled();
expect(audio.oscillators).toHaveLength(0);
});
});
describe('stop', () => {
it('should close the AudioContext', () => {
const tone = new ChimeTone(0);
tone.start();
tone.stop();
expect(audio.audioContext.close).toBeCalled();
});
it('should not fire finishedHandler on external stop', () => {
const tone = new ChimeTone(0);
const onFinished = vi.fn();
tone.start(onFinished);
tone.stop();
expect(onFinished).not.toBeCalled();
});
it('should swallow AudioContext.close rejections silently', () => {
vi.mocked(audio.audioContext.close).mockRejectedValue(new Error('already-closed'));
const tone = new ChimeTone(0);
tone.start();
expect(() => tone.stop()).not.toThrow();
});
});
describe('repeat counter', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should loop indefinitely when repeat is 0', () => {
new ChimeTone(0).start();
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
for (let i = 2; i <= 5; i++) {
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * i);
}
expect(audio.audioContext.close).not.toBeCalled();
});
it('should play exactly `repeat` iterations and then finish', () => {
const onFinished = vi.fn();
new ChimeTone(3).start(onFinished);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 2);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 3);
// After the third iteration the next timer waits one interval for the
// decay tail, then fires the finished handler and stops.
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 3);
expect(onFinished).toBeCalledTimes(1);
expect(audio.audioContext.close).toBeCalled();
});
it('should not fire finishedHandler when stopped mid-sequence', () => {
const tone = new ChimeTone(5);
const onFinished = vi.fn();
tone.start(onFinished);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
tone.stop();
// Even if any stale scheduled work fires, finishedHandler stays silent.
vi.advanceTimersByTime(ITERATION_INTERVAL_MS * 10);
expect(onFinished).not.toBeCalled();
});
});
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChimeTone } from '../../../../src/card-controller/call/tones/chime';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// Each strike emits a 3-layer bell stack in the order: sparkle (one octave
// above the fundamental), fundamental, hum (one octave below). The frequencies
// here are the per-layer values derived from each strike's fundamental.
describe('ChimeTone', () => {
it('should play DING (Eb5) then DOOOOONG (B4) 0.5s later', () => {
new ChimeTone(0).start();
// 2 strikes × 3 layers = 6 oscillators.
expect(audio.oscillators).toHaveLength(6);
// DING -- Eb5 (622.25Hz) at t=0.
expect(audio.oscillators[0].frequency.value).toBe(622.25 * 2);
expect(audio.oscillators[1].frequency.value).toBe(622.25);
expect(audio.oscillators[2].frequency.value).toBe(622.25 / 2);
expect(audio.oscillators[0].start).toBeCalledWith(0);
expect(audio.oscillators[1].start).toBeCalledWith(0);
expect(audio.oscillators[2].start).toBeCalledWith(0);
// DOOOOONG -- B4 (493.88Hz) at t=0.5.
expect(audio.oscillators[3].frequency.value).toBe(493.88 * 2);
expect(audio.oscillators[4].frequency.value).toBe(493.88);
expect(audio.oscillators[5].frequency.value).toBe(493.88 / 2);
expect(audio.oscillators[3].start).toBeCalledWith(0.5);
expect(audio.oscillators[4].start).toBeCalledWith(0.5);
expect(audio.oscillators[5].start).toBeCalledWith(0.5);
});
it('should give DING a brighter, shorter bell envelope', () => {
new ChimeTone(0).start();
// Sparkle / fundamental / hum peaks for DING.
expect(audio.gainParams[0].linearRampToValueAtTime).toBeCalledWith(0.1, 0.005);
expect(audio.gainParams[1].linearRampToValueAtTime).toBeCalledWith(0.22, 0.005);
expect(audio.gainParams[2].linearRampToValueAtTime).toBeCalledWith(0.08, 0.005);
// Decay constants (sparkle fades fastest, hum lingers).
expect(audio.gainParams[0].setTargetAtTime).toBeCalledWith(0, 0.005, 0.3);
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.8);
expect(audio.gainParams[2].setTargetAtTime).toBeCalledWith(0, 0.005, 1.2);
});
it('should give DOOOOONG a fuller, longer bell envelope', () => {
new ChimeTone(0).start();
expect(audio.gainParams[3].linearRampToValueAtTime).toBeCalledWith(0.11, 0.505);
expect(audio.gainParams[4].linearRampToValueAtTime).toBeCalledWith(0.28, 0.505);
expect(audio.gainParams[5].linearRampToValueAtTime).toBeCalledWith(0.1, 0.505);
expect(audio.gainParams[3].setTargetAtTime).toBeCalledWith(0, 0.505, 0.5);
expect(audio.gainParams[4].setTargetAtTime).toBeCalledWith(0, 0.505, 1.3);
expect(audio.gainParams[5].setTargetAtTime).toBeCalledWith(0, 0.505, 1.8);
});
describe('with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should schedule the next iteration 5 seconds after a strike pair', () => {
new ChimeTone(0).start();
expect(audio.oscillators).toHaveLength(6);
vi.advanceTimersByTime(5_000);
// A second iteration ran, producing another 6 oscillators.
expect(audio.oscillators).toHaveLength(12);
});
});
});
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { CustomTone } from '../../../../src/card-controller/call/tones/custom';
interface AudioMocks {
// Each call to `new Audio(...)` is delegated to a real jsdom Audio element
// and pushed here in construction order, so tests can dispatch real events
// and read real properties (`loop`, `currentTime`, etc.) on the instances.
instances: HTMLAudioElement[];
ctor: Mock<[string?], HTMLAudioElement>;
}
// Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom
// can't fulfil (`play()` / `pause()` — no audio backend). Tests then exercise
// observable behaviour: registered listeners fire via `dispatchEvent`,
// property writes round-trip on the element, etc.
//
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just
// as if they had been written at the top of the file — so every test in the
// file gets fresh mocks installed/torn down automatically.
const useAudioElementMocks = (): AudioMocks => {
const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks;
beforeEach(() => {
handle.instances = [];
// jsdom's HTMLMediaElement.play() rejects by default (no media backend);
// resolve it so the source's `.catch(...)` natural-finish path isn't
// triggered by every play call. Tests can override per-case.
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue();
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
const RealAudio = window.Audio;
handle.ctor = vi.fn((url?: string) => {
const audio = new RealAudio(url);
handle.instances.push(audio);
return audio;
});
vi.stubGlobal('Audio', handle.ctor);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
return handle;
};
const audio = useAudioElementMocks();
// @vitest-environment jsdom
describe('start', () => {
it('should construct an Audio element with the configured URL', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.ctor).toBeCalledWith('http://example/ring.mp3');
});
it('should loop indefinitely when repeat is 0', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.instances[0].loop).toBe(true);
expect(audio.instances[0].play).toBeCalled();
});
it('should re-play (not loop natively) when repeat is finite', () => {
new CustomTone('http://example/ring.mp3', 2).start();
expect(audio.instances[0].loop).not.toBe(true);
// Observable proof the 'ended' listener was registered: dispatching the
// event triggers a second play().
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(2);
});
it('should reset currentTime before play', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.instances[0].currentTime).toBe(0);
expect(audio.instances[0].play).toBeCalled();
});
it('should no-op when called twice without stop', () => {
const tone = new CustomTone('http://example/ring.mp3', 0);
tone.start();
tone.start();
expect(audio.ctor).toBeCalledTimes(1);
});
it('should fire finishedHandler when Audio construction throws', () => {
audio.ctor.mockImplementation(() => {
throw new Error('unsupported');
});
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
expect(onFinished).toBeCalled();
});
it('should fire finishedHandler when play() rejects (e.g. autoplay block)', async () => {
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue(
new Error('autoplay-blocked'),
);
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
// Let the rejected promise settle.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFinished).toBeCalled();
});
});
describe('repeat counter', () => {
it('should fire finishedHandler after the configured number of iterations', () => {
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 3).start(onFinished);
// Iteration 1 already started by `start()`. Two more 'ended' events
// should re-play, and the third 'ended' should finish.
expect(audio.instances[0].play).toBeCalledTimes(1);
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(2);
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(3);
expect(onFinished).not.toBeCalled();
audio.instances[0].dispatchEvent(new Event('ended'));
expect(onFinished).toBeCalledTimes(1);
});
it('should ignore ended events after stop', () => {
const onFinished = vi.fn();
const tone = new CustomTone('http://example/ring.mp3', 3);
tone.start(onFinished);
const element = audio.instances[0];
tone.stop();
// stop() should have removed the 'ended' listener, so dispatching is a
// no-op as far as the source is concerned.
element.dispatchEvent(new Event('ended'));
expect(onFinished).not.toBeCalled();
});
});
describe('stop', () => {
it('should pause and detach the audio element', () => {
const tone = new CustomTone('http://example/ring.mp3', 2);
tone.start();
const element = audio.instances[0];
tone.stop();
expect(element.pause).toBeCalled();
// Confirm the 'ended' listener is gone: dispatching it must not re-play.
vi.mocked(HTMLMediaElement.prototype.play).mockClear();
element.dispatchEvent(new Event('ended'));
expect(element.play).not.toBeCalled();
});
it('should not fire finishedHandler on external stop', () => {
const onFinished = vi.fn();
const tone = new CustomTone('http://example/ring.mp3', 0);
tone.start(onFinished);
tone.stop();
expect(onFinished).not.toBeCalled();
});
it('should be safe to call before start', () => {
expect(() => new CustomTone('http://example/ring.mp3', 0).stop()).not.toThrow();
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { MelodyTone } from '../../../../src/card-controller/call/tones/melody';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// MelodyTone synthesizes each chord as: 1 sparkle (an octave above the
// highest chord note) + 3 chord notes + 1 hum (an octave below the lowest).
// Three chords × 5 notes = 15 oscillators per iteration.
describe('MelodyTone', () => {
it('should play a I-V-I cadence in C major over 3 seconds', () => {
new MelodyTone(0).start();
expect(audio.oscillators).toHaveLength(15);
// --- I chord (C major) at t=0: sparkle G6, C5 + E5 + G5, hum C4. ---
expect(audio.oscillators[0].frequency.value).toBe(1567.98);
expect(audio.oscillators[1].frequency.value).toBe(523.25);
expect(audio.oscillators[2].frequency.value).toBe(659.25);
expect(audio.oscillators[3].frequency.value).toBe(783.99);
expect(audio.oscillators[4].frequency.value).toBe(261.63);
expect(audio.oscillators[0].start).toBeCalledWith(0);
expect(audio.oscillators[4].start).toBeCalledWith(0);
// --- V chord (G major) at t=1: sparkle D6, G4 + B4 + D5, hum G3. ---
expect(audio.oscillators[5].frequency.value).toBe(1174.66);
expect(audio.oscillators[6].frequency.value).toBe(392.0);
expect(audio.oscillators[7].frequency.value).toBe(493.88);
expect(audio.oscillators[8].frequency.value).toBe(587.33);
expect(audio.oscillators[9].frequency.value).toBe(196.0);
expect(audio.oscillators[5].start).toBeCalledWith(1);
expect(audio.oscillators[9].start).toBeCalledWith(1);
// --- I chord (resolution, an octave higher) at t=2. ---
expect(audio.oscillators[10].frequency.value).toBe(2093.0);
expect(audio.oscillators[11].frequency.value).toBe(659.25);
expect(audio.oscillators[12].frequency.value).toBe(783.99);
expect(audio.oscillators[13].frequency.value).toBe(1046.5);
expect(audio.oscillators[14].frequency.value).toBe(329.63);
expect(audio.oscillators[10].start).toBeCalledWith(2);
expect(audio.oscillators[14].start).toBeCalledWith(2);
});
it('should give the resolving chord a longer tail than the I and V chords', () => {
new MelodyTone(0).start();
// I and V chords use default fundDecay=0.6, humDecay=1.1.
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.6);
expect(audio.gainParams[4].setTargetAtTime).toBeCalledWith(0, 0.005, 1.1);
expect(audio.gainParams[6].setTargetAtTime).toBeCalledWith(0, 1.005, 0.6);
expect(audio.gainParams[9].setTargetAtTime).toBeCalledWith(0, 1.005, 1.1);
// Final I chord overrides to fundDecay=0.9, humDecay=1.4.
expect(audio.gainParams[11].setTargetAtTime).toBeCalledWith(0, 2.005, 0.9);
expect(audio.gainParams[14].setTargetAtTime).toBeCalledWith(0, 2.005, 1.4);
});
});
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, Mock, vi } from 'vitest';
import { mock, MockProxy } from 'vitest-mock-extended';
// Type-safe Web Audio API mocks for tone tests. The real `GeneratedTone`
// constructs an AudioContext directly; here we stub the global `AudioContext`
// to return a deep-mocked instance whose `createOscillator()` / `createGain()`
// factories return a *fresh* mock per call (not a shared one). This lets tests
// assert against individual notes — e.g. `audio.oscillators[2].frequency.value`
// — instead of having every call write over the same observable state.
interface AudioMocks {
audioContext: MockProxy<AudioContext>;
audioContextCtor: Mock<[], MockProxy<AudioContext>>;
// Filled in the order `createOscillator()` / `createGain()` were called.
oscillators: MockProxy<OscillatorNode>[];
gains: MockProxy<GainNode>[];
// gainParams[i] is the AudioParam exposed by `gains[i].gain` — kept as a
// parallel array because `mock<GainNode>()` doesn't auto-populate the
// AudioParam interface as a callable deep mock (we wire it up by hand).
gainParams: MockProxy<AudioParam>[];
}
// Installs `AudioContext` mock on the global scope and resets it between tests;
// restores real globals afterwards. Returns the live `audio` handle so tests
// can read its fields after each `beforeEach` runs.
//
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just as
// if they had been written at the top of the file — so every test in the file
// gets fresh mocks installed/torn down automatically.
export const useAudioMocks = (): AudioMocks => {
const audio = {} as AudioMocks;
beforeEach(() => {
audio.audioContext = mock<AudioContext>();
audio.oscillators = [];
audio.gains = [];
audio.gainParams = [];
vi.mocked(audio.audioContext.createOscillator).mockImplementation(() => {
const oscillator = mock<OscillatorNode>();
// `frequency` is a real AudioParam at runtime; the source assigns
// `oscillator.frequency.value = freq` which must round-trip on read.
Object.defineProperty(oscillator, 'frequency', {
value: { value: 0 },
configurable: true,
});
audio.oscillators.push(oscillator);
return oscillator;
});
vi.mocked(audio.audioContext.createGain).mockImplementation(() => {
const gain = mock<GainNode>();
const gainParam = mock<AudioParam>();
Object.defineProperty(gain, 'gain', {
value: gainParam,
configurable: true,
});
audio.gains.push(gain);
audio.gainParams.push(gainParam);
return gain;
});
// The source chains `.catch(...)` on the close() Promise.
vi.mocked(audio.audioContext.close).mockResolvedValue();
// The base class reads `_currentTime` from this — left as a deep-mock spy
// by default it'd return a function, so anchor it at 0 for predictable
// scheduling assertions.
Object.defineProperty(audio.audioContext, 'currentTime', {
value: 0,
configurable: true,
});
audio.audioContextCtor = vi.fn(() => audio.audioContext);
vi.stubGlobal('AudioContext', audio.audioContextCtor);
});
afterEach(() => {
vi.unstubAllGlobals();
});
return audio;
};
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { WestminsterTone } from '../../../../src/card-controller/call/tones/westminster';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// Each strike emits a 3-layer bell stack in order: sparkle (octave above
// fundamental), fundamental, hum (octave below). Indices into
// `audio.oscillators` step through the four strikes of the phrase.
describe('WestminsterTone', () => {
it('should play the E5-D5-C5-G4 phrase 0.55s apart', () => {
new WestminsterTone(0).start();
// 4 strikes × 3 layers = 12 oscillators.
expect(audio.oscillators).toHaveLength(12);
// E5 (659.25) at t=0.
expect(audio.oscillators[1].frequency.value).toBe(659.25);
expect(audio.oscillators[1].start).toBeCalledWith(0);
// D5 (587.33) at t=0.55.
expect(audio.oscillators[4].frequency.value).toBe(587.33);
expect(audio.oscillators[4].start).toBeCalledWith(0.55);
// C5 (523.25) at t=1.1.
expect(audio.oscillators[7].frequency.value).toBe(523.25);
expect(audio.oscillators[7].start).toBeCalledWith(1.1);
// G4 (392.0) at t=1.65 -- the resolution.
expect(audio.oscillators[10].frequency.value).toBe(392.0);
expect(audio.oscillators[10].start).toBeCalledWith(1.65);
});
it('should give the resolving G4 a longer bell tail than the other strikes', () => {
new WestminsterTone(0).start();
// First three strikes use default decay (fundDecay=0.6, humDecay=1.0).
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.6);
expect(audio.gainParams[2].setTargetAtTime).toBeCalledWith(0, 0.005, 1.0);
// G4 (final strike) overrides to fundDecay=0.9, humDecay=1.4.
expect(audio.gainParams[10].setTargetAtTime).toBeCalledWith(0, 1.65 + 0.005, 0.9);
expect(audio.gainParams[11].setTargetAtTime).toBeCalledWith(0, 1.65 + 0.005, 1.4);
});
});
@@ -143,6 +143,7 @@ describe('CardElementManager', () => {
expect(api.getExpandManager().initialize).toBeCalled();
expect(api.getMediaLoadedInfoManager().initialize).toBeCalled();
expect(api.getMicrophoneManager().initialize).toBeCalled();
expect(api.getCallManager().initialize).toBeCalled();
});
it('should disconnect', () => {
@@ -209,6 +210,7 @@ describe('CardElementManager', () => {
expect(api.getFullscreenManager().disconnect).toBeCalled();
expect(api.getKeyboardStateManager().uninitialize).toBeCalled();
expect(api.getActionsManager().uninitialize).toBeCalled();
expect(api.getCallManager().uninitialize).toBeCalled();
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
});
@@ -291,6 +291,55 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should handle trigger action set to call', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
// `start()` is called with the triggered camera and the inbound flag --
// view navigation is delegated to CallManager itself.
expect(api.getCallManager().start).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
});
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should start a call on a high-fidelity event with no media', async () => {
// The high-fidelity-no-media skip-guard intentionally lets `call`
// through -- calls don't depend on a new media item being available.
const api = createTriggerAPI({
config: {
actions: { trigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
fidelity: 'high',
});
expect(api.getCallManager().start).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
});
});
});
describe('untrigger actions', () => {
@@ -361,6 +410,38 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should handle untrigger action set to call', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'none', untrigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getCallManager().endIf).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
answered: false,
});
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should handle untrigger call with no state', async () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
+2
View File
@@ -94,6 +94,8 @@ describe('config defaults', () => {
call: {
button_size: 40,
lock: true,
ringtone: { type: 'chime', repeat: 0 },
unanswered_timeout_seconds: 60,
},
next_previous: {
auto_hide: ['call', 'casting'],
+35 -1
View File
@@ -195,13 +195,47 @@ describe('CarouselController', () => {
containScroll: 'trimSnaps',
watchSlides: false,
watchResize: true,
watchDrag: false,
watchDrag: expect.any(Function),
direction: 'rtl',
},
[],
);
});
it('should pass a watchDrag predicate reflecting the current drag state', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
const carousel = new CarouselController(root, parent, { dragEnabled: true });
const emblaOptions = vi.mocked(EmblaCarousel).mock.calls[0][1] as {
watchDrag: () => boolean;
};
expect(emblaOptions.watchDrag()).toBe(true);
carousel.setDragEnabled(false);
expect(emblaOptions.watchDrag()).toBe(false);
carousel.setDragEnabled(true);
expect(emblaOptions.watchDrag()).toBe(true);
});
it('should toggle drag without rebuilding the carousel', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent, {
dragEnabled: true,
});
const emblaApi = getEmblaApi();
expect(emblaApi).toBeTruthy();
carousel.setDragEnabled(false);
expect(emblaApi?.reInit).not.toBeCalled();
expect(emblaApi?.destroy).not.toBeCalled();
});
it('should include wheel plugin when slides > 1', () => {
const children = createTestSlideNodes();
const root = createRoot();