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
text/typescript
// 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
});
});
});