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!)
})
})
})
})