feat: Add support for inbound "calls" from triggers (#2500)
This commit is contained in:
committed by
dermotduffy
parent
f4c686c298
commit
15e335a647
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user