import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AudioService } from '../src/services/audioService'; import { usePlayerStore } from '../src/stores/playerStore'; // ── WaveSurfer mock ─────────────────────────────────────────────────────────── type EventName = 'ready' | 'error' | 'play' | 'pause' | 'finish' | 'audioprocess'; type EventHandler = (...args: any[]) => void; function createMockWaveSurfer(duration = 120) { const handlers: Partial> = {}; const ws = { on: vi.fn((event: EventName, handler: EventHandler) => { handlers[event] = handler; }), load: vi.fn(), play: vi.fn().mockResolvedValue(undefined), pause: vi.fn(), setTime: vi.fn(), getCurrentTime: vi.fn(() => 0), getDuration: vi.fn(() => duration), isPlaying: vi.fn(() => false), unAll: vi.fn(), destroy: vi.fn(), setOptions: vi.fn(), // Helper to fire events from tests _emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args), }; return ws; } vi.mock('wavesurfer.js', () => ({ default: { create: vi.fn() }, })); // ── Helpers ─────────────────────────────────────────────────────────────────── function makeContainer(): HTMLElement { return document.createElement('div'); } async function initService( service: AudioService, opts: { url?: string; duration?: number } = {} ): Promise> { const { url = 'http://example.com/audio.mp3', duration = 120 } = opts; const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs = createMockWaveSurfer(duration); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any); const initPromise = service.initialize(makeContainer(), url); // Fire 'ready' to complete initialization mockWs._emit('ready'); await initPromise; return mockWs; } // ── Tests ───────────────────────────────────────────────────────────────────── describe('AudioService', () => { let service: AudioService; beforeEach(() => { AudioService.resetInstance(); service = AudioService.getInstance(); usePlayerStore.getState().reset(); }); // ── initialize() ──────────────────────────────────────────────────────────── describe('initialize()', () => { it('throws if container is null', async () => { await expect( service.initialize(null as any, 'http://example.com/a.mp3') ).rejects.toThrow('Container element is required'); }); it('throws if url is empty', async () => { await expect( service.initialize(makeContainer(), '') ).rejects.toThrow('Valid audio URL is required'); }); it('resolves and marks ready when WaveSurfer fires ready with duration > 0', async () => { await initService(service); expect(service.isWaveformReady()).toBe(true); }); it('sets duration in the player store on ready', async () => { await initService(service, { duration: 180 }); expect(usePlayerStore.getState().duration).toBe(180); }); it('rejects and stays not-ready when duration is 0', async () => { const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs = createMockWaveSurfer(0); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any); const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3'); mockWs._emit('ready'); await expect(initPromise).rejects.toThrow('duration is 0'); expect(service.isWaveformReady()).toBe(false); }); it('rejects when WaveSurfer fires an error', async () => { const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any); const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3'); mockWs._emit('error', new Error('network error')); await expect(initPromise).rejects.toThrow('network error'); }); it('does nothing when called with same URL and same container', async () => { const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any); const url = 'http://example.com/same.mp3'; const container = makeContainer(); const p1 = service.initialize(container, url); mockWs._emit('ready'); await p1; const createCount = vi.mocked(WaveSurfer.create).mock.calls.length; await service.initialize(container, url); // exact same reference expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCount); expect(mockWs.setOptions).not.toHaveBeenCalled(); }); it('re-attaches waveform to new container when same URL but container changed (re-navigation)', async () => { const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any); const url = 'http://example.com/same.mp3'; const p1 = service.initialize(makeContainer(), url); mockWs._emit('ready'); await p1; const newContainer = makeContainer(); // different DOM element, same URL const createCount = vi.mocked(WaveSurfer.create).mock.calls.length; await service.initialize(newContainer, url); // No new WaveSurfer instance — just a canvas re-attach expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCount); expect(mockWs.setOptions).toHaveBeenCalledWith({ container: newContainer }); }); it('resets store state when URL changes', async () => { await initService(service, { url: 'http://example.com/a.mp3', duration: 180 }); usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 45 }); const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs2 = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs2 as any); const p2 = service.initialize(makeContainer(), 'http://example.com/b.mp3'); mockWs2._emit('ready'); await p2; // State should be reset, not carry over from the previous song expect(usePlayerStore.getState().currentTime).toBe(0); expect(usePlayerStore.getState().isPlaying).toBe(false); }); it('destroys old instance when URL changes', async () => { const WaveSurfer = (await import('wavesurfer.js')).default; const mockWs1 = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs1 as any); const p1 = service.initialize(makeContainer(), 'http://example.com/a.mp3'); mockWs1._emit('ready'); await p1; const mockWs2 = createMockWaveSurfer(); vi.mocked(WaveSurfer.create).mockReturnValue(mockWs2 as any); const p2 = service.initialize(makeContainer(), 'http://example.com/b.mp3'); mockWs2._emit('ready'); await p2; expect(mockWs1.destroy).toHaveBeenCalled(); }); }); // ── play() ─────────────────────────────────────────────────────────────────── describe('play()', () => { it('does nothing if not ready', async () => { // play() silently returns when not ready — no throw, no warn await expect(service.play('song-1', 'band-1')).resolves.toBeUndefined(); }); it('calls wavesurfer.play()', async () => { const mockWs = await initService(service); await service.play(); expect(mockWs.play).toHaveBeenCalled(); }); it('updates currentSongId/BandId in the store', async () => { await initService(service); await service.play('song-1', 'band-1'); const state = usePlayerStore.getState(); expect(state.currentSongId).toBe('song-1'); expect(state.currentBandId).toBe('band-1'); }); it('does not update store ids when called without ids', async () => { await initService(service); await service.play(); const state = usePlayerStore.getState(); expect(state.currentSongId).toBeNull(); }); }); // ── pause() ────────────────────────────────────────────────────────────────── describe('pause()', () => { it('calls wavesurfer.pause() when ready', async () => { const mockWs = await initService(service); service.pause(); expect(mockWs.pause).toHaveBeenCalled(); }); it('does nothing if not initialized', () => { expect(() => service.pause()).not.toThrow(); }); }); // ── seekTo() ───────────────────────────────────────────────────────────────── describe('seekTo()', () => { it('calls wavesurfer.setTime() with the given time', async () => { const mockWs = await initService(service); service.seekTo(42); expect(mockWs.setTime).toHaveBeenCalledWith(42); }); it('does nothing for non-finite values', async () => { const mockWs = await initService(service); service.seekTo(Infinity); service.seekTo(NaN); expect(mockWs.setTime).not.toHaveBeenCalled(); }); it('does nothing if not ready', () => { expect(() => service.seekTo(10)).not.toThrow(); }); }); // ── cleanup() ──────────────────────────────────────────────────────────────── describe('cleanup()', () => { it('destroys WaveSurfer and marks service not-ready', async () => { const mockWs = await initService(service); service.cleanup(); expect(mockWs.destroy).toHaveBeenCalled(); expect(service.isWaveformReady()).toBe(false); }); it('resets isPlaying, currentTime, and duration in the store', async () => { await initService(service, { duration: 180 }); usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 }); service.cleanup(); const state = usePlayerStore.getState(); expect(state.isPlaying).toBe(false); expect(state.currentTime).toBe(0); expect(state.duration).toBe(0); }); it('is safe to call when not initialized', () => { expect(() => service.cleanup()).not.toThrow(); }); }); // ── WaveSurfer event → store sync ──────────────────────────────────────────── describe('WaveSurfer event handlers', () => { it('play event sets store isPlaying=true', async () => { const mockWs = await initService(service); mockWs._emit('play'); expect(usePlayerStore.getState().isPlaying).toBe(true); }); it('pause event sets store isPlaying=false', async () => { const mockWs = await initService(service); usePlayerStore.getState().batchUpdate({ isPlaying: true }); mockWs._emit('pause'); expect(usePlayerStore.getState().isPlaying).toBe(false); }); it('finish event sets store isPlaying=false', async () => { const mockWs = await initService(service); usePlayerStore.getState().batchUpdate({ isPlaying: true }); mockWs._emit('finish'); expect(usePlayerStore.getState().isPlaying).toBe(false); }); it('audioprocess event updates store currentTime (throttled at 250ms)', async () => { const mockWs = await initService(service); // First emission at t=300 passes throttle (300 - lastUpdateTime:0 >= 250) vi.spyOn(Date, 'now').mockReturnValue(300); mockWs._emit('audioprocess', 15.5); expect(usePlayerStore.getState().currentTime).toBe(15.5); // Second emission at t=400 is throttled (400 - 300 = 100 < 250) vi.spyOn(Date, 'now').mockReturnValue(400); mockWs._emit('audioprocess', 16.0); expect(usePlayerStore.getState().currentTime).toBe(15.5); // unchanged }); }); });