import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { attachStallRetry } from './stallRetry'; // Minimal HTMLAudioElement stand-in for vitest. Real DOM elements // are over-featured; we only need the methods + addEventListener. function makeAudioStub() { const handlers = new Map>(); const stub = { currentTime: 0, paused: false, readyState: 4, error: null as MediaError | null, load: vi.fn(), play: vi.fn().mockResolvedValue(undefined), addEventListener(type: string, fn: EventListener) { if (!handlers.has(type)) handlers.set(type, new Set()); handlers.get(type)!.add(fn); }, removeEventListener(type: string, fn: EventListener) { handlers.get(type)?.delete(fn); }, fire(type: string) { handlers.get(type)?.forEach((fn) => fn(new Event(type))); } }; return stub; } describe('attachStallRetry', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); test('stalled event triggers reload after delayMs when buffer not recovered', () => { const el = makeAudioStub(); el.currentTime = 42; el.readyState = 1; // not yet HAVE_FUTURE_DATA attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 }); el.fire('stalled'); expect(el.load).not.toHaveBeenCalled(); // not yet — waiting vi.advanceTimersByTime(1000); expect(el.load).toHaveBeenCalledTimes(1); expect(el.currentTime).toBe(42); // restored after load() expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false) }); test('skips reload when readyState recovered before delay elapses', () => { const el = makeAudioStub(); el.readyState = 1; attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 }); el.fire('stalled'); el.readyState = 4; // recovered vi.advanceTimersByTime(1000); expect(el.load).not.toHaveBeenCalled(); }); test('canplay clears retry counter so a later stall can retry again', () => { const el = makeAudioStub(); el.readyState = 1; attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 100, maxRetries: 1 }); el.fire('stalled'); vi.advanceTimersByTime(100); expect(el.load).toHaveBeenCalledTimes(1); el.fire('canplay'); // resets retry count el.fire('stalled'); vi.advanceTimersByTime(100); expect(el.load).toHaveBeenCalledTimes(2); }); test('maxRetries caps recovery attempts', () => { const el = makeAudioStub(); el.readyState = 1; attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 100, maxRetries: 2 }); for (let i = 0; i < 5; i++) { el.fire('stalled'); vi.advanceTimersByTime(100); } expect(el.load).toHaveBeenCalledTimes(2); }); test('MEDIA_ERR_NETWORK error triggers immediate reload', () => { const el = makeAudioStub(); el.error = { code: 2 } as MediaError; el.currentTime = 17; attachStallRetry(el as unknown as HTMLAudioElement); el.fire('error'); expect(el.load).toHaveBeenCalled(); expect(el.currentTime).toBe(17); }); test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => { const el = makeAudioStub(); el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE attachStallRetry(el as unknown as HTMLAudioElement); el.fire('error'); expect(el.load).not.toHaveBeenCalled(); }); test('teardown removes listeners and clears pending timer', () => { const el = makeAudioStub(); el.readyState = 1; const teardown = attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 100 }); el.fire('stalled'); teardown(); vi.advanceTimersByTime(100); expect(el.load).not.toHaveBeenCalled(); // After teardown, firing events does nothing. el.fire('stalled'); vi.advanceTimersByTime(100); expect(el.load).not.toHaveBeenCalled(); }); });