diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index 87a708d..a287fe8 100755 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -22,21 +22,21 @@ export function useWaveform( containerRef: React.RefObject, options: UseWaveformOptions ) { - const [isPlaying, setIsPlaying] = useState(false); const [isReady, setIsReady] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); const [error, setError] = useState(null); const markersRef = useRef([]); + // 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(() => { if (!containerRef.current) 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 () => { try { 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); options.onReady?.(audioService.getDuration()); } catch (err) { @@ -101,12 +82,6 @@ export function useWaveform( }; initializeAudio(); - - return () => { - if (animationFrameId !== null) { - cancelAnimationFrame(animationFrameId); - } - }; }, [options.url, options.songId, options.bandId]); const play = () => { diff --git a/web/tests/audioContextInitialization.test.ts b/web/tests/audioContextInitialization.test.ts deleted file mode 100644 index 2b2f1cf..0000000 --- a/web/tests/audioContextInitialization.test.ts +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/web/tests/audioService.test.ts b/web/tests/audioService.test.ts index 102036e..b61fa4e 100644 --- a/web/tests/audioService.test.ts +++ b/web/tests/audioService.test.ts @@ -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 { usePlayerStore } from '../src/stores/playerStore'; -// Mock WaveSurfer -function createMockWaveSurfer() { - return { - backend: { - getAudioContext: vi.fn(() => ({ - state: 'running', - sampleRate: 44100, - destination: { channelCount: 2 }, - resume: vi.fn().mockResolvedValue(undefined), - onstatechange: null - })), - ac: null, - audioContext: null - }, - getAudioContext: vi.fn(), - on: vi.fn(), +// ── 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(), + play: vi.fn().mockResolvedValue(undefined), pause: vi.fn(), + setTime: vi.fn(), getCurrentTime: vi.fn(() => 0), - getDuration: vi.fn(() => 120), + getDuration: vi.fn(() => duration), isPlaying: vi.fn(() => false), unAll: 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') { - return { - state, - sampleRate: 44100, - destination: { channelCount: 2 }, - resume: vi.fn().mockResolvedValue(undefined), - onstatechange: null - }; +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 audioService: AudioService; - let mockWaveSurfer: any; - let mockAudioContext: any; + let service: AudioService; beforeEach(() => { - // Reset the singleton instance AudioService.resetInstance(); - audioService = AudioService.getInstance(); - - mockWaveSurfer = createMockWaveSurfer(); - mockAudioContext = createMockAudioContext(); - - // Mock window.AudioContext - (globalThis as any).window = { - AudioContext: vi.fn(() => mockAudioContext) as any - }; + service = AudioService.getInstance(); + usePlayerStore.getState().reset(); }); - afterEach(() => { - vi.restoreAllMocks(); + // ── 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(); + }); }); -describe('setupAudioContext', () => { - it('should successfully access audio context via backend.getAudioContext()', () => { - audioService['setupAudioContext'](mockWaveSurfer); - - expect(mockWaveSurfer.backend.getAudioContext).toHaveBeenCalled(); - expect(audioService['audioContext']).toBeDefined(); - expect(audioService['audioContext'].state).toBe('running'); + // ── 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(); + }); }); - it('should fall back to ws.getAudioContext() if backend method fails', () => { - const mockWaveSurferNoBackend = { - ...mockWaveSurfer, - backend: null, - getAudioContext: vi.fn(() => mockAudioContext) - }; - - audioService['setupAudioContext'](mockWaveSurferNoBackend); - - expect(mockWaveSurferNoBackend.getAudioContext).toHaveBeenCalled(); - expect(audioService['audioContext']).toBeDefined(); + // ── 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(); + }); }); - it('should handle case when no audio context methods work but not throw error', () => { - const mockWaveSurferNoMethods = { - ...mockWaveSurfer, - backend: { - getAudioContext: null, - ac: null, - audioContext: null - }, - getAudioContext: null - }; - - // Should not throw error - just continue without audio context - audioService['setupAudioContext'](mockWaveSurferNoMethods); - - // Audio context should remain null in this case - expect(audioService['audioContext']).toBeNull(); + // ── 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(); + }); }); - it('should handle suspended audio context by resuming it', () => { - const suspendedContext = createMockAudioContext('suspended'); - mockWaveSurfer.backend.getAudioContext.mockReturnValue(suspendedContext); - - audioService['setupAudioContext'](mockWaveSurfer); - - expect(suspendedContext.resume).toHaveBeenCalled(); + // ── 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(); + }); }); - it('should not throw error if audio context cannot be created - just continue', () => { - global.window.AudioContext = vi.fn(() => { - throw new Error('AudioContext creation failed'); - }) as any; - - const mockWaveSurferNoMethods = { - ...mockWaveSurfer, - backend: { - getAudioContext: null, - ac: null, - audioContext: null - }, - getAudioContext: null - }; - - // Should not throw error - just continue without audio context - expect(() => audioService['setupAudioContext'](mockWaveSurferNoMethods)) - .not.toThrow(); - expect(audioService['audioContext']).toBeNull(); + // ── 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 + }); }); }); - -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(); - }); -}); - -}); \ No newline at end of file diff --git a/web/tests/loggingOptimization.test.ts b/web/tests/loggingOptimization.test.ts deleted file mode 100644 index 37763cd..0000000 --- a/web/tests/loggingOptimization.test.ts +++ /dev/null @@ -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; - }); - }); -}); \ No newline at end of file