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>
This commit is contained in:
@@ -22,21 +22,21 @@ export function useWaveform(
|
|||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
options: UseWaveformOptions
|
options: UseWaveformOptions
|
||||||
) {
|
) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
const markersRef = useRef<CommentMarker[]>([]);
|
||||||
|
|
||||||
|
// Playback state comes directly from the store — no intermediate local state
|
||||||
|
// or RAF polling loop needed. The store is updated by WaveSurfer event handlers
|
||||||
|
// in AudioService, so these values are always in sync.
|
||||||
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||||
|
const currentTime = usePlayerStore(state => state.currentTime);
|
||||||
|
const duration = usePlayerStore(state => state.duration);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
|
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
|
||||||
|
|
||||||
// animationFrameId is declared here so the useEffect cleanup can cancel it
|
|
||||||
// even if initializeAudio hasn't finished yet
|
|
||||||
let animationFrameId: number | null = null;
|
|
||||||
|
|
||||||
const initializeAudio = async () => {
|
const initializeAudio = async () => {
|
||||||
try {
|
try {
|
||||||
await audioService.initialize(containerRef.current!, options.url!);
|
await audioService.initialize(containerRef.current!, options.url!);
|
||||||
@@ -72,25 +72,6 @@ export function useWaveform(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync local state from the store at ~15fps via RAF.
|
|
||||||
// The loop is started after initialization so we only poll when loaded.
|
|
||||||
let lastUpdateTime = 0;
|
|
||||||
const updateInterval = 1000 / 15;
|
|
||||||
|
|
||||||
const handleStateUpdate = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastUpdateTime >= updateInterval) {
|
|
||||||
const state = usePlayerStore.getState();
|
|
||||||
setIsPlaying(state.isPlaying);
|
|
||||||
setCurrentTime(state.currentTime);
|
|
||||||
setDuration(state.duration);
|
|
||||||
lastUpdateTime = now;
|
|
||||||
}
|
|
||||||
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
|
||||||
};
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
|
||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
options.onReady?.(audioService.getDuration());
|
options.onReady?.(audioService.getDuration());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,12 +82,6 @@ export function useWaveform(
|
|||||||
};
|
};
|
||||||
|
|
||||||
initializeAudio();
|
initializeAudio();
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [options.url, options.songId, options.bandId]);
|
}, [options.url, options.songId, options.bandId]);
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { AudioService } from '../src/services/audioService';
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('AudioService Audio Context Initialization', () => {
|
|
||||||
let audioService: AudioService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset the singleton instance before each test
|
|
||||||
AudioService.resetInstance();
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Mock AudioContext for testing
|
|
||||||
global.window.AudioContext = vi.fn().mockImplementation(() => ({
|
|
||||||
state: 'suspended',
|
|
||||||
sampleRate: 44100,
|
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onstatechange: null,
|
|
||||||
suspend: vi.fn().mockResolvedValue(undefined)
|
|
||||||
})) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Clean up any audio contexts
|
|
||||||
if (audioService['audioContext']) {
|
|
||||||
audioService['audioContext'].close?.().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up mock
|
|
||||||
delete global.window.AudioContext;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize audio context successfully', async () => {
|
|
||||||
const context = await audioService.initializeAudioContext();
|
|
||||||
expect(context).toBeDefined();
|
|
||||||
expect(context.state).toBe('suspended'); // Should start suspended
|
|
||||||
expect(audioService['audioContext']).toBe(context);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle audio context resume', async () => {
|
|
||||||
// Initialize context first
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Mock user gesture by resuming
|
|
||||||
const resumeSpy = vi.spyOn(audioService['audioContext']!, 'resume');
|
|
||||||
|
|
||||||
// This should attempt to resume the context
|
|
||||||
await audioService['handleAudioContextResume']();
|
|
||||||
|
|
||||||
expect(resumeSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should share audio context with WaveSurfer', async () => {
|
|
||||||
// Initialize audio context
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Create mock WaveSurfer instance
|
|
||||||
const mockWaveSurfer = {
|
|
||||||
backend: {
|
|
||||||
audioContext: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call the sharing method
|
|
||||||
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
|
|
||||||
|
|
||||||
// Verify context was shared
|
|
||||||
expect(mockWaveSurfer.backend.audioContext).toBe(audioService['audioContext']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle WaveSurfer without backend gracefully', async () => {
|
|
||||||
// Initialize audio context
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Create mock WaveSurfer instance without backend
|
|
||||||
const mockWaveSurfer = {
|
|
||||||
getAudioContext: vi.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
// This should not throw
|
|
||||||
expect(() => {
|
|
||||||
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check initialization state correctly', async () => {
|
|
||||||
// Initially should not be initialized
|
|
||||||
expect(audioService.isInitialized()).toBe(false);
|
|
||||||
|
|
||||||
// After audio context initialization, still not fully initialized
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
expect(audioService.isInitialized()).toBe(false);
|
|
||||||
|
|
||||||
// Note: Full initialization would require WaveSurfer instance
|
|
||||||
// which we can't easily mock here without DOM
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,259 +1,288 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AudioService } from '../src/services/audioService';
|
import { AudioService } from '../src/services/audioService';
|
||||||
|
import { usePlayerStore } from '../src/stores/playerStore';
|
||||||
|
|
||||||
// Mock WaveSurfer
|
// ── WaveSurfer mock ───────────────────────────────────────────────────────────
|
||||||
function createMockWaveSurfer() {
|
|
||||||
return {
|
type EventName = 'ready' | 'error' | 'play' | 'pause' | 'finish' | 'audioprocess';
|
||||||
backend: {
|
type EventHandler = (...args: any[]) => void;
|
||||||
getAudioContext: vi.fn(() => ({
|
|
||||||
state: 'running',
|
function createMockWaveSurfer(duration = 120) {
|
||||||
sampleRate: 44100,
|
const handlers: Partial<Record<EventName, EventHandler>> = {};
|
||||||
destination: { channelCount: 2 },
|
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
const ws = {
|
||||||
onstatechange: null
|
on: vi.fn((event: EventName, handler: EventHandler) => {
|
||||||
})),
|
handlers[event] = handler;
|
||||||
ac: null,
|
}),
|
||||||
audioContext: null
|
|
||||||
},
|
|
||||||
getAudioContext: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
load: vi.fn(),
|
load: vi.fn(),
|
||||||
play: vi.fn(),
|
play: vi.fn().mockResolvedValue(undefined),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
|
setTime: vi.fn(),
|
||||||
getCurrentTime: vi.fn(() => 0),
|
getCurrentTime: vi.fn(() => 0),
|
||||||
getDuration: vi.fn(() => 120),
|
getDuration: vi.fn(() => duration),
|
||||||
isPlaying: vi.fn(() => false),
|
isPlaying: vi.fn(() => false),
|
||||||
unAll: vi.fn(),
|
unAll: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
setTime: vi.fn()
|
// Helper to fire events from tests
|
||||||
|
_emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args),
|
||||||
};
|
};
|
||||||
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockAudioContext(state: 'suspended' | 'running' | 'closed' = 'running') {
|
vi.mock('wavesurfer.js', () => ({
|
||||||
return {
|
default: { create: vi.fn() },
|
||||||
state,
|
}));
|
||||||
sampleRate: 44100,
|
|
||||||
destination: { channelCount: 2 },
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onstatechange: null
|
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', () => {
|
describe('AudioService', () => {
|
||||||
let audioService: AudioService;
|
let service: AudioService;
|
||||||
let mockWaveSurfer: any;
|
|
||||||
let mockAudioContext: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset the singleton instance
|
|
||||||
AudioService.resetInstance();
|
AudioService.resetInstance();
|
||||||
audioService = AudioService.getInstance();
|
service = AudioService.getInstance();
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
mockWaveSurfer = createMockWaveSurfer();
|
|
||||||
mockAudioContext = createMockAudioContext();
|
|
||||||
|
|
||||||
// Mock window.AudioContext
|
|
||||||
(globalThis as any).window = {
|
|
||||||
AudioContext: vi.fn(() => mockAudioContext) as any
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
// ── initialize() ────────────────────────────────────────────────────────────
|
||||||
vi.restoreAllMocks();
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setupAudioContext', () => {
|
// ── play() ───────────────────────────────────────────────────────────────────
|
||||||
it('should successfully access audio context via backend.getAudioContext()', () => {
|
|
||||||
audioService['setupAudioContext'](mockWaveSurfer);
|
|
||||||
|
|
||||||
expect(mockWaveSurfer.backend.getAudioContext).toHaveBeenCalled();
|
describe('play()', () => {
|
||||||
expect(audioService['audioContext']).toBeDefined();
|
it('does nothing if not ready', async () => {
|
||||||
expect(audioService['audioContext'].state).toBe('running');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to ws.getAudioContext() if backend method fails', () => {
|
// ── pause() ──────────────────────────────────────────────────────────────────
|
||||||
const mockWaveSurferNoBackend = {
|
|
||||||
...mockWaveSurfer,
|
|
||||||
backend: null,
|
|
||||||
getAudioContext: vi.fn(() => mockAudioContext)
|
|
||||||
};
|
|
||||||
|
|
||||||
audioService['setupAudioContext'](mockWaveSurferNoBackend);
|
describe('pause()', () => {
|
||||||
|
it('calls wavesurfer.pause() when ready', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
service.pause();
|
||||||
|
expect(mockWs.pause).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockWaveSurferNoBackend.getAudioContext).toHaveBeenCalled();
|
it('does nothing if not initialized', () => {
|
||||||
expect(audioService['audioContext']).toBeDefined();
|
expect(() => service.pause()).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle case when no audio context methods work but not throw error', () => {
|
// ── seekTo() ─────────────────────────────────────────────────────────────────
|
||||||
const mockWaveSurferNoMethods = {
|
|
||||||
...mockWaveSurfer,
|
|
||||||
backend: {
|
|
||||||
getAudioContext: null,
|
|
||||||
ac: null,
|
|
||||||
audioContext: null
|
|
||||||
},
|
|
||||||
getAudioContext: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw error - just continue without audio context
|
describe('seekTo()', () => {
|
||||||
audioService['setupAudioContext'](mockWaveSurferNoMethods);
|
it('calls wavesurfer.setTime() with the given time', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
service.seekTo(42);
|
||||||
|
expect(mockWs.setTime).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
// Audio context should remain null in this case
|
it('does nothing for non-finite values', async () => {
|
||||||
expect(audioService['audioContext']).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle suspended audio context by resuming it', () => {
|
// ── cleanup() ────────────────────────────────────────────────────────────────
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
mockWaveSurfer.backend.getAudioContext.mockReturnValue(suspendedContext);
|
|
||||||
|
|
||||||
audioService['setupAudioContext'](mockWaveSurfer);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw error if audio context cannot be created - just continue', () => {
|
// ── WaveSurfer event → store sync ────────────────────────────────────────────
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('AudioContext creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
const mockWaveSurferNoMethods = {
|
describe('WaveSurfer event handlers', () => {
|
||||||
...mockWaveSurfer,
|
it('play event sets store isPlaying=true', async () => {
|
||||||
backend: {
|
const mockWs = await initService(service);
|
||||||
getAudioContext: null,
|
mockWs._emit('play');
|
||||||
ac: null,
|
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||||
audioContext: null
|
});
|
||||||
},
|
|
||||||
getAudioContext: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw error - just continue without audio context
|
it('pause event sets store isPlaying=false', async () => {
|
||||||
expect(() => audioService['setupAudioContext'](mockWaveSurferNoMethods))
|
const mockWs = await initService(service);
|
||||||
.not.toThrow();
|
usePlayerStore.getState().batchUpdate({ isPlaying: true });
|
||||||
expect(audioService['audioContext']).toBeNull();
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureAudioContext', () => {
|
|
||||||
it('should return existing audio context if available', async () => {
|
|
||||||
audioService['audioContext'] = mockAudioContext;
|
|
||||||
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(result).toBe(mockAudioContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resume suspended audio context', async () => {
|
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
audioService['audioContext'] = suspendedContext;
|
|
||||||
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(suspendedContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create new audio context if none exists', async () => {
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(global.window.AudioContext).toHaveBeenCalled();
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.state).toBe('running');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error if audio context creation fails', async () => {
|
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('Creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
await expect(audioService['ensureAudioContext']())
|
|
||||||
.rejects
|
|
||||||
.toThrow('Audio context creation failed: Creation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getWaveSurferVersion', () => {
|
|
||||||
it('should return WaveSurfer version if available', () => {
|
|
||||||
audioService['wavesurfer'] = {
|
|
||||||
version: '7.12.5'
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBe('7.12.5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return unknown if version not available', () => {
|
|
||||||
audioService['wavesurfer'] = {} as any;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if no wavesurfer instance', () => {
|
|
||||||
audioService['wavesurfer'] = null;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initializeAudioContext', () => {
|
|
||||||
it('should initialize audio context successfully', async () => {
|
|
||||||
const result = await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.state).toBe('running');
|
|
||||||
expect(audioService['audioContext']).toBe(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resume suspended audio context', async () => {
|
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
global.window.AudioContext = vi.fn(() => suspendedContext) as any;
|
|
||||||
|
|
||||||
const result = await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(suspendedContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle audio context creation errors', async () => {
|
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('AudioContext creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
await expect(audioService.initializeAudioContext())
|
|
||||||
.rejects
|
|
||||||
.toThrow('Failed to initialize audio context: AudioContext creation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanup', () => {
|
|
||||||
it('should stop playback and clean up properly', () => {
|
|
||||||
// Mock a playing wavesurfer instance
|
|
||||||
const mockWavesurfer = {
|
|
||||||
isPlaying: vi.fn(() => true),
|
|
||||||
pause: vi.fn(),
|
|
||||||
unAll: vi.fn(),
|
|
||||||
destroy: vi.fn()
|
|
||||||
};
|
|
||||||
audioService['wavesurfer'] = mockWavesurfer;
|
|
||||||
|
|
||||||
audioService['currentPlayingSongId'] = 'song-123';
|
|
||||||
audioService['currentPlayingBandId'] = 'band-456';
|
|
||||||
|
|
||||||
audioService.cleanup();
|
|
||||||
|
|
||||||
expect(mockWavesurfer.pause).toHaveBeenCalled();
|
|
||||||
expect(mockWavesurfer.unAll).toHaveBeenCalled();
|
|
||||||
expect(mockWavesurfer.destroy).toHaveBeenCalled();
|
|
||||||
expect(audioService['wavesurfer']).toBeNull();
|
|
||||||
expect(audioService['currentPlayingSongId']).toBeNull();
|
|
||||||
expect(audioService['currentPlayingBandId']).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle cleanup when no wavesurfer instance exists', () => {
|
|
||||||
audioService['wavesurfer'] = null;
|
|
||||||
audioService['currentPlayingSongId'] = 'song-123';
|
|
||||||
|
|
||||||
expect(() => audioService.cleanup()).not.toThrow();
|
|
||||||
expect(audioService['currentPlayingSongId']).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { AudioService, LogLevel } from '../src/services/audioService';
|
|
||||||
|
|
||||||
describe('AudioService Logging Optimization', () => {
|
|
||||||
let audioService: AudioService;
|
|
||||||
let consoleSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
AudioService.resetInstance();
|
|
||||||
|
|
||||||
// Spy on console methods
|
|
||||||
consoleSpy = {
|
|
||||||
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
|
||||||
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
|
|
||||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
|
||||||
error: vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Environment-based Log Level Detection', () => {
|
|
||||||
it('should use DEBUG level in development environment (localhost)', () => {
|
|
||||||
// Mock localhost environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('DEBUG')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use WARN level in production environment', () => {
|
|
||||||
// Mock production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('WARN')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use DEBUG level with audioDebug query parameter', () => {
|
|
||||||
// Mock production environment with debug parameter
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = {
|
|
||||||
hostname: 'example.com',
|
|
||||||
search: '?audioDebug=true'
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('log level: DEBUG')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Log Throttling', () => {
|
|
||||||
it('should throttle rapid-fire log calls', () => {
|
|
||||||
// Mock development environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Call log multiple times rapidly
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
audioService['log'](LogLevel.DEBUG, `Test log ${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only log a few times due to throttling
|
|
||||||
expect(consoleSpy.debug).toHaveBeenCalled();
|
|
||||||
// Should be called fewer times due to throttling
|
|
||||||
const callCount = consoleSpy.debug.mock.calls.length;
|
|
||||||
expect(callCount).toBeLessThanOrEqual(3);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Log Level Filtering', () => {
|
|
||||||
it('should filter out logs below current log level', () => {
|
|
||||||
// Mock production environment (WARN level)
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Try to log INFO level message in WARN environment
|
|
||||||
audioService['log'](LogLevel.INFO, 'This should not appear');
|
|
||||||
|
|
||||||
// Should not call console.info
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('This should not appear')
|
|
||||||
);
|
|
||||||
|
|
||||||
// WARN level should appear
|
|
||||||
audioService['log'](LogLevel.WARN, 'This should appear');
|
|
||||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('This should appear')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow DEBUG logs in development environment', () => {
|
|
||||||
// Mock development environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// DEBUG level should appear in development
|
|
||||||
audioService['log'](LogLevel.DEBUG, 'Debug message');
|
|
||||||
expect(consoleSpy.debug).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Debug message')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Verbose Log Reduction', () => {
|
|
||||||
it('should not log play/pause/seek calls in production', () => {
|
|
||||||
// Mock production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// These should not appear in production (INFO level, but production uses WARN)
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.play called');
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.pause called');
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.seekTo called');
|
|
||||||
|
|
||||||
// Should not call console.info for these
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.play called')
|
|
||||||
);
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.pause called')
|
|
||||||
);
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.seekTo called')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log errors in both environments', () => {
|
|
||||||
// Test in production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Error logs should always appear
|
|
||||||
audioService['log'](LogLevel.ERROR, 'Critical error');
|
|
||||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Critical error')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user