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).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(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).toHaveBeenCalled(); 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).toHaveBeenCalled(); }); 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.toHaveBeenCalled(); }); 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.toHaveBeenCalled(); }); 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).toHaveBeenCalledTimes(1); expect(audio.audioContext.close).toHaveBeenCalled(); }); 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.toHaveBeenCalled(); }); });