Bug 1 — playback stops on navigation: WaveSurfer v7 creates its <audio> element inside the container div. When SongPage unmounts, the container is removed from the DOM, taking the audio element with it and stopping playback. Fix: AudioService owns a persistent hidden <audio> element on document.body and passes it to WaveSurfer via the `media` option. WaveSurfer uses it for playback but does not destroy it on WaveSurfer.destroy(), so audio survives any number of navigations. Bug 2 — stale playhead/duration when opening a new song: initialize() called destroyWaveSurfer() but never reset the store, so the previous song's currentTime, duration, and isPlaying leaked into the new song's load sequence. Fix: reset those three fields in the store immediately after tearing down the old WaveSurfer instance. cleanup() also now resets duration. Bug 3 — excessive console noise on mobile: Remove console.warn from play() (silent return when not ready) and from useWaveform's play() wrapper. Only console.error on actual errors remains. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
294 lines
12 KiB
TypeScript
294 lines
12 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('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
|
|
});
|
|
});
|
|
});
|