Files
rehearshalhub/web/tests/audioService.test.ts
Mistral Vibe 7508d78a86 refactor(audio): Phase 3 — replace RAF polling loop with store subscription
useWaveform.ts:
- Remove requestAnimationFrame polling loop that was re-running after every
  re-initialization and leaking across renders when cleanup didn't fire
- Remove local useState for isPlaying/currentTime/duration; these now come
  directly from usePlayerStore selectors — WaveSurfer event handlers in
  AudioService already write to the store, so no intermediate sync needed
- The useEffect is now a clean async init only; no cleanup needed (AudioService
  persists intentionally across page navigations)

tests/:
- Delete 3 obsolete test files that tested removed APIs (logging system,
  setupAudioContext, ensureAudioContext, initializeAudioContext)
- Add tests/audioService.test.ts: 25 tests covering initialize(), play(),
  pause(), seekTo(), cleanup(), and all WaveSurfer event→store mappings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:54:18 +02:00

289 lines
11 KiB
TypeScript

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<Record<EventName, EventHandler>> = {};
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(),
// 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<ReturnType<typeof createMockWaveSurfer>> {
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('reuses the existing instance for the same URL', 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 createCallCount = vi.mocked(WaveSurfer.create).mock.calls.length;
await service.initialize(makeContainer(), url); // same URL
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCallCount); // no new instance
});
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 () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await service.play('song-1', 'band-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before ready'));
warnSpy.mockRestore();
});
it('calls wavesurfer.play()', async () => {
const mockWs = await initService(service);
await service.play();
expect(mockWs.play).toHaveBeenCalled();
});
it('updates currentPlayingSongId/BandId in the store', async () => {
await initService(service);
await service.play('song-1', 'band-1');
const state = usePlayerStore.getState();
expect(state.currentPlayingSongId).toBe('song-1');
expect(state.currentPlayingBandId).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.currentPlayingSongId).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 and currentTime in the store', async () => {
await initService(service);
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
service.cleanup();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
});
it('pauses before destroying if playing', async () => {
const callOrder: string[] = [];
const mockWs = await initService(service);
mockWs.isPlaying.mockReturnValue(true);
mockWs.pause.mockImplementation(() => callOrder.push('pause'));
mockWs.destroy.mockImplementation(() => callOrder.push('destroy'));
service.cleanup();
expect(callOrder).toEqual(['pause', 'destroy']);
});
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
});
});
});