import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest' import { render, act, waitFor } from '@testing-library/react' import React from 'react' import { PlaybackContinuityService } from '../PlaybackContinuityService' import { SmartAudioLoader } from '../SmartAudioLoader' import { usePlaybackContinuity } from '../../hooks/usePlaybackContinuity' import { MediaItem } from '../../api/jellyfin' // Mock fetch globally global.fetch = vi.fn() // Mock localStorage const localStorageMock = { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), } Object.defineProperty(window, 'localStorage', { value: localStorageMock }) // Mock navigator.onLine Object.defineProperty(navigator, 'onLine', { writable: true, value: true, }) // Test component that uses the playback continuity hook const TestComponent: React.FC = () => { const { playbackState, connectionState, isOnline, connectionQuality, updatePlaybackState, syncPlaybackPosition, getSmartFallbackUrl, seamlessHandoff } = usePlaybackContinuity() return (
{isOnline ? 'online' : 'offline'}
{connectionQuality}
{playbackState?.trackId || 'none'}
{playbackState?.position || 0}
) } describe('Playback Continuity Integration', () => { let mockFetch: Mock beforeEach(() => { vi.clearAllMocks() mockFetch = fetch as Mock // Reset singleton instance ;(PlaybackContinuityService as any).instance = null // Mock localStorage responses localStorageMock.getItem.mockImplementation((key: string) => { if (key === 'deviceId') return 'test-device-123' return null }) }) afterEach(() => { // Clean up service instance const service = PlaybackContinuityService.getInstance() service.destroy() }) describe('End-to-End Playback Continuity', () => { it('should handle complete offline to online transition', async () => { // Start offline Object.defineProperty(navigator, 'onLine', { value: false }) const { getByTestId } = render() // Should start in offline mode expect(getByTestId('online-status')).toHaveTextContent('offline') expect(getByTestId('connection-quality')).toHaveTextContent('offline') // Update playback state while offline act(() => { getByTestId('update-state').click() }) await waitFor(() => { expect(getByTestId('current-track')).toHaveTextContent('test-track') expect(getByTestId('playback-position')).toHaveTextContent('30.5') }) // Mock successful server sync mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) // Go online act(() => { Object.defineProperty(navigator, 'onLine', { value: true }) window.dispatchEvent(new Event('online')) }) await waitFor(() => { expect(getByTestId('online-status')).toHaveTextContent('online') }) // Should attempt to sync state to server await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/playback-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.stringContaining('test-track') }) }) }) it('should sync playback position with sub-second accuracy', async () => { const { getByTestId } = render() // Set initial state act(() => { getByTestId('update-state').click() }) // Sync position with high precision act(() => { getByTestId('sync-position').click() }) await waitFor(() => { expect(getByTestId('playback-position')).toHaveTextContent('45.678') }) }) it('should handle cross-device state synchronization', async () => { // Mock server returning state from another device const serverState = { trackId: 'server-track', position: 120.5, isPlaying: true, volume: 0.9, queue: [], currentIndex: 1, shuffle: true, repeat: 'all', timestamp: Date.now(), deviceId: 'other-device' } mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(serverState) }) const { getByTestId } = render() // Trigger connection recovery (which fetches server state) act(() => { Object.defineProperty(navigator, 'onLine', { value: true }) window.dispatchEvent(new Event('online')) }) await waitFor(() => { expect(getByTestId('current-track')).toHaveTextContent('server-track') expect(getByTestId('playback-position')).toHaveTextContent('120.5') }) }) it('should handle connection quality changes', async () => { const { getByTestId } = render() // Mock poor connection response mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) // Simulate slow response (poor connection) await new Promise(resolve => setTimeout(resolve, 400)) await waitFor(() => { // Connection quality should be updated based on response time const quality = getByTestId('connection-quality').textContent expect(['excellent', 'good', 'poor']).toContain(quality) }) }) }) describe('Smart Audio Loading Integration', () => { let loader: SmartAudioLoader let mockAudioElement: HTMLAudioElement let mockAudioRef: React.RefObject let mockHlsRef: React.RefObject const mockTrack: MediaItem = { Id: 'integration-track', Name: 'Integration Test Track' } as MediaItem beforeEach(() => { loader = new SmartAudioLoader() mockAudioElement = { pause: vi.fn(), load: vi.fn(), currentTime: 0, src: '' } as any mockAudioRef = { current: mockAudioElement } mockHlsRef = { current: null } }) it('should integrate with continuity service for smart fallback', async () => { const mockAudioStorage = { getPlayableUrl: vi.fn().mockResolvedValue({ url: 'blob://cached-integration', type: 'audio' }), getTrack: vi.fn().mockResolvedValue({ type: 'audio' }) } const mockApi = { getStreamUrl: vi.fn().mockReturnValue('https://server/integration-stream') } const result = await loader.loadTrackWithFallback( mockTrack, mockAudioRef, mockHlsRef, mockAudioStorage, mockApi, 320000 ) expect(result.success).toBe(true) expect(result.source).toBe('cache') expect(mockAudioElement.src).toBe('blob://cached-integration') }) it('should handle network fallback when cache fails', async () => { const mockAudioStorage = { getPlayableUrl: vi.fn().mockRejectedValue(new Error('Cache miss')), getTrack: vi.fn().mockResolvedValue(null) } const mockApi = { getStreamUrl: vi.fn().mockReturnValue('https://server/fallback-stream') } // Mock successful network test mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) const result = await loader.loadTrackWithFallback( mockTrack, mockAudioRef, mockHlsRef, mockAudioStorage, mockApi, 320000 ) expect(result.success).toBe(true) expect(result.source).toBe('network') expect(mockAudioElement.src).toBe('https://server/fallback-stream') }) it('should handle complete failure gracefully', async () => { const mockAudioStorage = { getPlayableUrl: vi.fn().mockRejectedValue(new Error('Cache miss')), getTrack: vi.fn().mockResolvedValue(null) } const mockApi = { getStreamUrl: vi.fn().mockReturnValue('https://server/failed-stream') } // Mock network failure mockFetch.mockRejectedValueOnce(new Error('Network error')) const result = await loader.loadTrackWithFallback( mockTrack, mockAudioRef, mockHlsRef, mockAudioStorage, mockApi, 320000 ) expect(result.success).toBe(false) expect(result.source).toBe('error') expect(result.error).toContain('not available offline') }) }) describe('Performance and Reliability', () => { it('should handle rapid state updates without issues', async () => { const { getByTestId } = render() // Rapidly update state multiple times for (let i = 0; i < 10; i++) { act(() => { getByTestId('update-state').click() }) } // Should handle all updates without errors await waitFor(() => { expect(getByTestId('current-track')).toHaveTextContent('test-track') }) }) it('should handle network interruptions gracefully', async () => { const { getByTestId } = render() // Start online expect(getByTestId('online-status')).toHaveTextContent('online') // Simulate network interruption act(() => { Object.defineProperty(navigator, 'onLine', { value: false }) window.dispatchEvent(new Event('offline')) }) await waitFor(() => { expect(getByTestId('online-status')).toHaveTextContent('offline') }) // Update state while offline act(() => { getByTestId('update-state').click() }) // Should still work offline await waitFor(() => { expect(getByTestId('current-track')).toHaveTextContent('test-track') }) // Restore connection mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) act(() => { Object.defineProperty(navigator, 'onLine', { value: true }) window.dispatchEvent(new Event('online')) }) await waitFor(() => { expect(getByTestId('online-status')).toHaveTextContent('online') }) }) it('should maintain state consistency across connection changes', async () => { const { getByTestId } = render() // Set initial state act(() => { getByTestId('update-state').click() }) const initialTrack = getByTestId('current-track').textContent const initialPosition = getByTestId('playback-position').textContent // Go offline and back online act(() => { Object.defineProperty(navigator, 'onLine', { value: false }) window.dispatchEvent(new Event('offline')) }) mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) act(() => { Object.defineProperty(navigator, 'onLine', { value: true }) window.dispatchEvent(new Event('online')) }) // State should remain consistent await waitFor(() => { expect(getByTestId('current-track')).toHaveTextContent(initialTrack!) expect(getByTestId('playback-position')).toHaveTextContent(initialPosition!) }) }) }) })