UNPKG

z-web-audio-stream

Version:

iOS Safari-safe Web Audio streaming with separated download/storage optimization, instant playback, and memory management

430 lines (342 loc) 15.8 kB
// DownloadManager.test.ts // Unit tests for the DownloadManager class with mock HTTP requests import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { DownloadManager, type DownloadManagerOptions, type DownloadProgress, type DownloadChunk } from '../DownloadManager.js'; // Mock fetch globally global.fetch = vi.fn(); describe('DownloadManager', () => { let downloadManager: DownloadManager; let mockFetch: ReturnType<typeof vi.fn>; let progressCallback: ReturnType<typeof vi.fn>; let chunkCallback: ReturnType<typeof vi.fn>; let completeCallback: ReturnType<typeof vi.fn>; let errorCallback: ReturnType<typeof vi.fn>; beforeEach(() => { mockFetch = vi.mocked(fetch); progressCallback = vi.fn(); chunkCallback = vi.fn(); completeCallback = vi.fn(); errorCallback = vi.fn(); // Reset all mocks vi.clearAllMocks(); }); afterEach(() => { downloadManager?.cleanup(); }); const createMockResponse = (data: ArrayBuffer, status = 200, headers: Record<string, string> = {}) => { return Promise.resolve({ ok: status >= 200 && status < 300, status, headers: new Map(Object.entries({ 'content-length': data.byteLength.toString(), 'accept-ranges': 'bytes', ...headers })), arrayBuffer: () => Promise.resolve(data), } as Response); }; const createTestAudioData = (size: number): ArrayBuffer => { const buffer = new ArrayBuffer(size); const view = new Uint8Array(buffer); // Fill with some test pattern for (let i = 0; i < size; i++) { view[i] = i % 256; } return buffer; }; describe('Constructor and Configuration', () => { it('should initialize with default strategy', () => { downloadManager = new DownloadManager(); expect(downloadManager).toBeDefined(); }); it('should apply custom strategy options', () => { const customStrategy = { initialChunkSize: 128 * 1024, standardChunkSize: 512 * 1024, maxConcurrentDownloads: 2, priorityFirstChunk: false, adaptiveChunkSizing: false }; downloadManager = new DownloadManager({ strategy: customStrategy, onProgress: progressCallback }); expect(downloadManager).toBeDefined(); }); it('should detect iOS Safari and adjust strategy', () => { // Mock iOS Safari user agent Object.defineProperty(navigator, 'userAgent', { writable: true, value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1' }); downloadManager = new DownloadManager(); expect(downloadManager).toBeDefined(); // iOS Safari should have smaller chunk sizes and fewer concurrent downloads }); }); describe('Range Request Support Detection', () => { it('should detect range request support from server headers', async () => { const testData = createTestAudioData(1024 * 1024); // 1MB mockFetch.mockResolvedValueOnce(createMockResponse(testData.slice(0, 1024), 206, { 'accept-ranges': 'bytes', 'content-range': 'bytes 0-1023/1048576' })); downloadManager = new DownloadManager(); const supportsRange = await downloadManager.checkRangeRequestSupport('https://example.com/audio.mp3'); expect(supportsRange).toBe(true); expect(mockFetch).toHaveBeenCalledWith('https://example.com/audio.mp3', { method: 'GET', headers: { 'Range': 'bytes=0-1023' } }); }); it('should handle servers that do not support range requests', async () => { const testData = createTestAudioData(1024); mockFetch.mockResolvedValueOnce(createMockResponse(testData, 200, { 'accept-ranges': 'none' })); downloadManager = new DownloadManager(); const supportsRange = await downloadManager.checkRangeRequestSupport('https://example.com/audio.mp3'); expect(supportsRange).toBe(false); }); }); describe('Chunked Download Process', () => { it('should download file in multiple chunks with range requests', async () => { const fileSize = 512 * 1024; // 512KB file const chunkSize = 128 * 1024; // 128KB chunks const totalData = createTestAudioData(fileSize); // Mock range request support check mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206, { 'accept-ranges': 'bytes', 'content-range': `bytes 0-1023/${fileSize}` })); // Mock chunked downloads for (let i = 0; i < Math.ceil(fileSize / chunkSize); i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize - 1, fileSize - 1); const chunkData = totalData.slice(start, end + 1); mockFetch.mockResolvedValueOnce(createMockResponse(chunkData, 206, { 'content-range': `bytes ${start}-${end}/${fileSize}` })); } downloadManager = new DownloadManager({ strategy: { initialChunkSize: chunkSize, standardChunkSize: chunkSize }, onProgress: progressCallback, onChunkComplete: chunkCallback, onComplete: completeCallback }); const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(chunks).toHaveLength(4); // 512KB / 128KB = 4 chunks expect(progressCallback).toHaveBeenCalled(); expect(chunkCallback).toHaveBeenCalledTimes(4); expect(completeCallback).toHaveBeenCalledOnce(); // Verify chunks have correct data chunks.forEach((chunk, index) => { expect(chunk.index).toBe(index); expect(chunk.data).toBeInstanceOf(ArrayBuffer); expect(chunk.downloadTime).toBeGreaterThan(0); }); }); it('should handle priority first chunk download', async () => { const fileSize = 256 * 1024; // 256KB file const totalData = createTestAudioData(fileSize); // Mock range support mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // Mock first chunk (priority) mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 64 * 1024), 206)); // Mock remaining chunks mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(64 * 1024), 206)); downloadManager = new DownloadManager({ strategy: { initialChunkSize: 64 * 1024, standardChunkSize: 192 * 1024, priorityFirstChunk: true }, onChunkComplete: chunkCallback }); const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(chunks).toHaveLength(2); // First chunk should be completed first (priority) const firstChunkCall = chunkCallback.mock.calls[0][0] as DownloadChunk; expect(firstChunkCall.index).toBe(0); expect(firstChunkCall.data.byteLength).toBe(64 * 1024); }); it('should handle concurrent downloads with max limit', async () => { const fileSize = 1024 * 1024; // 1MB file const chunkSize = 128 * 1024; // 128KB chunks = 8 chunks const maxConcurrent = 3; const totalData = createTestAudioData(fileSize); // Mock range support mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // Mock all chunk downloads for (let i = 0; i < 8; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize - 1, fileSize - 1); const chunkData = totalData.slice(start, end + 1); mockFetch.mockResolvedValueOnce(createMockResponse(chunkData, 206)); } downloadManager = new DownloadManager({ strategy: { initialChunkSize: chunkSize, standardChunkSize: chunkSize, maxConcurrentDownloads: maxConcurrent } }); const startTime = Date.now(); const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); const endTime = Date.now(); expect(chunks).toHaveLength(8); // With proper concurrency, this should complete faster than sequential expect(endTime - startTime).toBeLessThan(1000); // Should be fast with mocks }); }); describe('Adaptive Chunk Sizing', () => { it('should adapt chunk size based on connection speed', async () => { const fileSize = 512 * 1024; const totalData = createTestAudioData(fileSize); // Mock slow connection (simulate delay) mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // Mock slow first chunk download mockFetch.mockImplementation(() => { return new Promise(resolve => { setTimeout(() => { resolve(createMockResponse(totalData.slice(0, 64 * 1024), 206)); }, 100); // Simulate slow network }); }); downloadManager = new DownloadManager({ strategy: { adaptiveChunkSizing: true, initialChunkSize: 64 * 1024, standardChunkSize: 128 * 1024 } }); // This would normally adapt chunk sizes based on measured speed const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(chunks.length).toBeGreaterThan(0); }); }); describe('Progress Tracking', () => { it('should report accurate progress during download', async () => { const fileSize = 256 * 1024; const chunkSize = 64 * 1024; const totalData = createTestAudioData(fileSize); mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // Mock sequential chunk downloads for (let i = 0; i < 4; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize - 1, fileSize - 1); mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(start, end + 1), 206)); } downloadManager = new DownloadManager({ onProgress: progressCallback }); await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(progressCallback).toHaveBeenCalled(); // Check that progress values make sense const progressCalls = progressCallback.mock.calls.map(call => call[0] as DownloadProgress); progressCalls.forEach(progress => { expect(progress.bytesLoaded).toBeGreaterThanOrEqual(0); expect(progress.bytesTotal).toBe(fileSize); expect(progress.chunksCompleted).toBeGreaterThanOrEqual(0); expect(progress.chunksTotal).toBeGreaterThan(0); }); // Final call should show completion const finalProgress = progressCalls[progressCalls.length - 1]; expect(finalProgress.bytesLoaded).toBe(fileSize); expect(finalProgress.chunksCompleted).toBe(finalProgress.chunksTotal); }); it('should calculate download speed accurately', async () => { const fileSize = 128 * 1024; const totalData = createTestAudioData(fileSize); mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206)); downloadManager = new DownloadManager({ onProgress: progressCallback }); await downloadManager.downloadFile('https://example.com/audio.mp3'); const progressCalls = progressCallback.mock.calls.map(call => call[0] as DownloadProgress); const lastProgress = progressCalls[progressCalls.length - 1]; expect(lastProgress.downloadSpeed).toBeGreaterThan(0); expect(lastProgress.estimatedTimeRemaining).toBe(0); // Should be 0 when complete }); }); describe('Error Handling', () => { it('should handle network errors gracefully', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); downloadManager = new DownloadManager({ onError: errorCallback }); await expect(downloadManager.downloadFile('https://example.com/audio.mp3')) .rejects.toThrow('Network error'); expect(errorCallback).toHaveBeenCalledWith(expect.any(Error)); }); it('should handle HTTP error responses', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response); downloadManager = new DownloadManager({ onError: errorCallback }); await expect(downloadManager.downloadFile('https://example.com/audio.mp3')) .rejects.toThrow(); }); it('should retry failed chunk downloads', async () => { const fileSize = 128 * 1024; const totalData = createTestAudioData(fileSize); // Range support check mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // First attempt fails mockFetch.mockRejectedValueOnce(new Error('Temporary network error')); // Second attempt succeeds mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206)); downloadManager = new DownloadManager(); const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(chunks).toHaveLength(1); expect(mockFetch).toHaveBeenCalledTimes(3); // range check + failed attempt + successful retry }); }); describe('Memory Management', () => { it('should cleanup resources after download', async () => { const fileSize = 64 * 1024; const totalData = createTestAudioData(fileSize); mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206)); downloadManager = new DownloadManager(); await downloadManager.downloadFile('https://example.com/audio.mp3'); // Call cleanup downloadManager.cleanup(); // After cleanup, internal state should be reset expect(downloadManager).toBeDefined(); // Manager still exists but cleaned up }); }); describe('iOS Safari Optimizations', () => { beforeEach(() => { // Mock iOS Safari Object.defineProperty(navigator, 'userAgent', { writable: true, value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15' }); }); it('should use smaller chunk sizes on iOS Safari', () => { downloadManager = new DownloadManager(); // iOS should automatically get smaller chunk sizes and fewer concurrent downloads expect(downloadManager).toBeDefined(); }); it('should limit concurrent downloads on iOS Safari', async () => { const fileSize = 256 * 1024; const totalData = createTestAudioData(fileSize); mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206)); // Mock multiple chunks for (let i = 0; i < 4; i++) { mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(i * 64 * 1024, (i + 1) * 64 * 1024), 206)); } downloadManager = new DownloadManager({ strategy: { maxConcurrentDownloads: 2 } // iOS limit }); const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3'); expect(chunks.length).toBeGreaterThan(0); }); }); });