UNPKG

z-web-audio-stream

Version:

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

470 lines (361 loc) 17 kB
// StreamingAssembler.test.ts // Unit tests for the StreamingAssembler class with chunk assembly logic import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StreamingAssembler, type StreamingAssemblerOptions, type AssemblyChunk } from '../StreamingAssembler.js'; import { type DownloadChunk } from '../DownloadManager.js'; describe('StreamingAssembler', () => { let assembler: StreamingAssembler; let chunkAssembledCallback: ReturnType<typeof vi.fn>; let playbackReadyCallback: ReturnType<typeof vi.fn>; let progressCallback: ReturnType<typeof vi.fn>; beforeEach(() => { chunkAssembledCallback = vi.fn(); playbackReadyCallback = vi.fn(); progressCallback = vi.fn(); vi.clearAllMocks(); }); afterEach(() => { assembler?.cleanup(); }); const createTestDownloadChunk = (index: number, size: number, start: number = 0): DownloadChunk => { const data = new ArrayBuffer(size); const view = new Uint8Array(data); // Fill with test pattern based on index for (let i = 0; i < size; i++) { view[i] = (index * 100 + i) % 256; } return { index, start, end: start + size - 1, data, downloadTime: 100 + index * 10 // Simulate varying download times }; }; const createTestOptions = (overrides: Partial<StreamingAssemblerOptions> = {}): StreamingAssemblerOptions => { return { storageChunkSize: 2 * 1024 * 1024, // 2MB storage chunks playbackChunkSize: 384 * 1024, // 384KB playback chunks onChunkAssembled: chunkAssembledCallback, onPlaybackReady: playbackReadyCallback, onProgress: progressCallback, ...overrides }; }; describe('Constructor and Configuration', () => { it('should initialize with provided options', () => { const options = createTestOptions(); assembler = new StreamingAssembler(options); expect(assembler).toBeDefined(); }); it('should handle custom chunk sizes', () => { const options = createTestOptions({ storageChunkSize: 1024 * 1024, // 1MB playbackChunkSize: 256 * 1024 // 256KB }); assembler = new StreamingAssembler(options); expect(assembler).toBeDefined(); }); }); describe('Download Chunk Processing', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should accept and queue download chunks', () => { const chunk1 = createTestDownloadChunk(0, 128 * 1024); // 128KB const chunk2 = createTestDownloadChunk(1, 128 * 1024); // 128KB assembler.addDownloadChunk(chunk1); assembler.addDownloadChunk(chunk2); // Should not trigger assembly yet (not enough for storage chunk) expect(chunkAssembledCallback).not.toHaveBeenCalled(); }); it('should handle out-of-order chunk arrival', () => { const chunk0 = createTestDownloadChunk(0, 128 * 1024, 0); const chunk2 = createTestDownloadChunk(2, 128 * 1024, 256 * 1024); const chunk1 = createTestDownloadChunk(1, 128 * 1024, 128 * 1024); // Add chunks out of order assembler.addDownloadChunk(chunk0); assembler.addDownloadChunk(chunk2); assembler.addDownloadChunk(chunk1); // Should handle reordering internally expect(assembler).toBeDefined(); }); it('should detect when first playback chunk is ready', () => { const playbackSize = 384 * 1024; // 384KB playback chunk size // Add chunks that total to playback size const chunk1 = createTestDownloadChunk(0, 256 * 1024); // 256KB const chunk2 = createTestDownloadChunk(1, 128 * 1024); // 128KB assembler.addDownloadChunk(chunk1); expect(playbackReadyCallback).not.toHaveBeenCalled(); assembler.addDownloadChunk(chunk2); // Should trigger playback ready callback expect(playbackReadyCallback).toHaveBeenCalledOnce(); expect(playbackReadyCallback).toHaveBeenCalledWith(expect.objectContaining({ canStartPlayback: true, storageIndex: 0, totalSize: expect.any(Number) })); }); }); describe('Assembly Logic', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should assemble download chunks into storage chunks', () => { const storageSize = 2 * 1024 * 1024; // 2MB storage chunk size // Add enough small chunks to trigger assembly const chunkSize = 256 * 1024; // 256KB chunks const chunksNeeded = Math.ceil(storageSize / chunkSize); // 8 chunks for (let i = 0; i < chunksNeeded; i++) { const chunk = createTestDownloadChunk(i, chunkSize, i * chunkSize); assembler.addDownloadChunk(chunk); } expect(chunkAssembledCallback).toHaveBeenCalled(); const assembledChunk = chunkAssembledCallback.mock.calls[0][0] as AssemblyChunk; expect(assembledChunk.storageIndex).toBe(0); expect(assembledChunk.downloadChunks).toHaveLength(chunksNeeded); expect(assembledChunk.totalSize).toBe(storageSize); expect(assembledChunk.data).toBeInstanceOf(ArrayBuffer); }); it('should create properly sized first chunk for playback', () => { const playbackSize = 384 * 1024; // 384KB // Add exactly enough data for first playback chunk const chunk1 = createTestDownloadChunk(0, 256 * 1024); const chunk2 = createTestDownloadChunk(1, 128 * 1024); assembler.addDownloadChunk(chunk1); assembler.addDownloadChunk(chunk2); expect(playbackReadyCallback).toHaveBeenCalledOnce(); const playbackChunk = playbackReadyCallback.mock.calls[0][0] as AssemblyChunk; expect(playbackChunk.canStartPlayback).toBe(true); expect(playbackChunk.totalSize).toBe(playbackSize); }); it('should continue assembly for subsequent storage chunks', () => { const storageSize = 2 * 1024 * 1024; // 2MB const chunkSize = 128 * 1024; // 128KB chunks // Add enough chunks for multiple storage chunks const totalChunks = Math.ceil((storageSize * 2.5) / chunkSize); // 2.5 storage chunks worth for (let i = 0; i < totalChunks; i++) { const chunk = createTestDownloadChunk(i, chunkSize, i * chunkSize); assembler.addDownloadChunk(chunk); } // Should have assembled multiple storage chunks expect(chunkAssembledCallback).toHaveBeenCalledTimes(2); const firstAssembly = chunkAssembledCallback.mock.calls[0][0] as AssemblyChunk; const secondAssembly = chunkAssembledCallback.mock.calls[1][0] as AssemblyChunk; expect(firstAssembly.storageIndex).toBe(0); expect(secondAssembly.storageIndex).toBe(1); }); it('should handle final partial chunks correctly', () => { const storageSize = 2 * 1024 * 1024; // 2MB const chunkSize = 128 * 1024; // 128KB // Add one full storage chunk + partial const fullChunks = Math.ceil(storageSize / chunkSize); const partialChunks = 3; // Partial final assembly for (let i = 0; i < fullChunks + partialChunks; i++) { const chunk = createTestDownloadChunk(i, chunkSize, i * chunkSize); assembler.addDownloadChunk(chunk); } // Signal completion to trigger final assembly assembler.finalize(); expect(chunkAssembledCallback).toHaveBeenCalledTimes(2); const finalAssembly = chunkAssembledCallback.mock.calls[1][0] as AssemblyChunk; expect(finalAssembly.totalSize).toBe(partialChunks * chunkSize); }); }); describe('Data Integrity', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should preserve data integrity during assembly', () => { const chunk1 = createTestDownloadChunk(0, 256 * 1024); const chunk2 = createTestDownloadChunk(1, 256 * 1024); assembler.addDownloadChunk(chunk1); assembler.addDownloadChunk(chunk2); expect(playbackReadyCallback).toHaveBeenCalled(); const assembledChunk = playbackReadyCallback.mock.calls[0][0] as AssemblyChunk; const assembledView = new Uint8Array(assembledChunk.data); // Verify first chunk data const chunk1View = new Uint8Array(chunk1.data); for (let i = 0; i < chunk1.data.byteLength; i++) { expect(assembledView[i]).toBe(chunk1View[i]); } // Verify second chunk data const chunk2View = new Uint8Array(chunk2.data); for (let i = 0; i < chunk2.data.byteLength; i++) { expect(assembledView[chunk1.data.byteLength + i]).toBe(chunk2View[i]); } }); it('should maintain correct byte order in assembled data', () => { const testPattern = [0xAA, 0xBB, 0xCC, 0xDD]; // Create small chunks with known patterns const createPatternChunk = (index: number, pattern: number[]): DownloadChunk => { const data = new ArrayBuffer(pattern.length); const view = new Uint8Array(data); pattern.forEach((byte, i) => view[i] = byte); return { index, start: index * pattern.length, end: (index + 1) * pattern.length - 1, data, downloadTime: 100 }; }; const chunk1 = createPatternChunk(0, [0x01, 0x02, 0x03, 0x04]); const chunk2 = createPatternChunk(1, [0x05, 0x06, 0x07, 0x08]); assembler.addDownloadChunk(chunk1); assembler.addDownloadChunk(chunk2); expect(playbackReadyCallback).toHaveBeenCalled(); const assembled = playbackReadyCallback.mock.calls[0][0] as AssemblyChunk; const view = new Uint8Array(assembled.data); expect(view[0]).toBe(0x01); expect(view[1]).toBe(0x02); expect(view[2]).toBe(0x03); expect(view[3]).toBe(0x04); expect(view[4]).toBe(0x05); expect(view[5]).toBe(0x06); expect(view[6]).toBe(0x07); expect(view[7]).toBe(0x08); }); }); describe('Memory Management', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should release download chunks after assembly', () => { const storageSize = 2 * 1024 * 1024; const chunkSize = 256 * 1024; const chunksNeeded = Math.ceil(storageSize / chunkSize); for (let i = 0; i < chunksNeeded; i++) { const chunk = createTestDownloadChunk(i, chunkSize); assembler.addDownloadChunk(chunk); } expect(chunkAssembledCallback).toHaveBeenCalled(); // Internal download chunks should be cleared after assembly // (This would be verified by internal state inspection in real implementation) }); it('should cleanup all resources', () => { const chunk = createTestDownloadChunk(0, 128 * 1024); assembler.addDownloadChunk(chunk); assembler.cleanup(); // After cleanup, should handle new operations gracefully expect(assembler).toBeDefined(); }); }); describe('Progress Tracking', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should report assembly progress accurately', () => { const totalSize = 1024 * 1024; // 1MB total const chunkSize = 128 * 1024; // 128KB chunks const totalChunks = totalSize / chunkSize; // 8 chunks assembler.setExpectedSize(totalSize); for (let i = 0; i < totalChunks; i++) { const chunk = createTestDownloadChunk(i, chunkSize); assembler.addDownloadChunk(chunk); } expect(progressCallback).toHaveBeenCalled(); const progressCalls = progressCallback.mock.calls.map(call => call[0]); expect(progressCalls.some(([assembled, total]) => assembled === totalChunks && total === totalChunks)).toBe(true); }); }); describe('iOS Safari Optimizations', () => { beforeEach(() => { // Mock iOS Safari environment 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 iOS-optimized chunk sizes', () => { const iosOptions = createTestOptions({ storageChunkSize: 1 * 1024 * 1024, // 1MB for iOS (smaller) playbackChunkSize: 256 * 1024 // 256KB for iOS }); assembler = new StreamingAssembler(iosOptions); const chunk1 = createTestDownloadChunk(0, 128 * 1024); const chunk2 = createTestDownloadChunk(1, 128 * 1024); assembler.addDownloadChunk(chunk1); assembler.addDownloadChunk(chunk2); expect(playbackReadyCallback).toHaveBeenCalled(); const playbackChunk = playbackReadyCallback.mock.calls[0][0] as AssemblyChunk; expect(playbackChunk.totalSize).toBe(256 * 1024); // iOS playback size }); it('should handle memory pressure scenarios', () => { const iosOptions = createTestOptions({ storageChunkSize: 512 * 1024, // Very small for iOS memory pressure playbackChunkSize: 128 * 1024 }); assembler = new StreamingAssembler(iosOptions); // Add multiple small chunks for (let i = 0; i < 8; i++) { const chunk = createTestDownloadChunk(i, 64 * 1024); assembler.addDownloadChunk(chunk); } // Should handle many small chunks efficiently expect(chunkAssembledCallback).toHaveBeenCalled(); }); }); describe('Edge Cases', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should handle single chunk larger than playback size', () => { const largeChunk = createTestDownloadChunk(0, 512 * 1024); // Larger than 384KB playback size assembler.addDownloadChunk(largeChunk); expect(playbackReadyCallback).toHaveBeenCalled(); const playbackChunk = playbackReadyCallback.mock.calls[0][0] as AssemblyChunk; expect(playbackChunk.canStartPlayback).toBe(true); expect(playbackChunk.totalSize).toBe(512 * 1024); // Should use the actual chunk size }); it('should handle zero-byte chunks', () => { const validChunk = createTestDownloadChunk(0, 128 * 1024); const emptyChunk = createTestDownloadChunk(1, 0); assembler.addDownloadChunk(validChunk); assembler.addDownloadChunk(emptyChunk); // Should handle empty chunks gracefully expect(assembler).toBeDefined(); }); it('should handle very large files efficiently', () => { const largeFileSize = 100 * 1024 * 1024; // 100MB assembler.setExpectedSize(largeFileSize); // Add a few chunks from a large file for (let i = 0; i < 5; i++) { const chunk = createTestDownloadChunk(i, 512 * 1024); // 512KB chunks assembler.addDownloadChunk(chunk); } expect(playbackReadyCallback).toHaveBeenCalled(); expect(progressCallback).toHaveBeenCalled(); }); }); describe('Performance', () => { beforeEach(() => { assembler = new StreamingAssembler(createTestOptions()); }); it('should assemble chunks efficiently', () => { const startTime = performance.now(); // Add many small chunks for (let i = 0; i < 100; i++) { const chunk = createTestDownloadChunk(i, 32 * 1024); // 32KB chunks assembler.addDownloadChunk(chunk); } const endTime = performance.now(); const duration = endTime - startTime; // Assembly should be fast (under 100ms for 100 chunks) expect(duration).toBeLessThan(100); }); it('should have minimal memory overhead', () => { const initialMemory = process.memoryUsage?.()?.heapUsed || 0; // Add substantial data for (let i = 0; i < 50; i++) { const chunk = createTestDownloadChunk(i, 128 * 1024); assembler.addDownloadChunk(chunk); } assembler.cleanup(); const finalMemory = process.memoryUsage?.()?.heapUsed || 0; const memoryIncrease = finalMemory - initialMemory; // Should not leak significant memory expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB increase }); }); });