feat: publish av-tools 0.1.0
This commit is contained in:
211
tests/unit/waveform-runtime-worker.test.ts
Normal file
211
tests/unit/waveform-runtime-worker.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
aggregatePcmPeaks,
|
||||
createWaveformRuntime,
|
||||
WaveformCancellationError,
|
||||
WaveformRuntimeBusyError,
|
||||
WaveformRuntimeTerminatedError,
|
||||
type WaveformWorkerLike,
|
||||
} from '../../src/waveform';
|
||||
import type { WaveformWorkerAggregateRequest } from '../../src/waveform/waveform.types';
|
||||
|
||||
describe('waveform worker runtime', () => {
|
||||
it('offloads aggregation, reports progress, and preserves caller bytes', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const input = new Int16Array([-32_768, 0, 16_384, 32_767]);
|
||||
const originalBytes = [...new Uint8Array(input.buffer)];
|
||||
const progress = vi.fn();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
|
||||
const resultPromise = runtime.aggregate(
|
||||
{
|
||||
container: 'raw',
|
||||
data: input,
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 2, onProgress: progress }
|
||||
);
|
||||
const request = worker.lastRequest;
|
||||
expect(request).toBeDefined();
|
||||
expect(request?.input.data).not.toBe(input.buffer);
|
||||
expect([...new Uint8Array(input.buffer)]).toEqual(originalBytes);
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'progress',
|
||||
requestId: request?.requestId,
|
||||
progress: 0.5,
|
||||
});
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: request?.input.data ?? new ArrayBuffer(0),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 2 }
|
||||
);
|
||||
worker.emitMessage({
|
||||
type: 'result',
|
||||
requestId: request?.requestId,
|
||||
peaks,
|
||||
});
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ bucketCount: 2 });
|
||||
expect(progress).toHaveBeenCalledWith(0.5);
|
||||
expect(progress).toHaveBeenLastCalledWith(1);
|
||||
expect(worker.terminated).toBe(true);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('terminates the worker on AbortSignal cancellation and remains reusable', async () => {
|
||||
const workers: FakeWorker[] = [];
|
||||
const runtime = createWaveformRuntime({
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const first = runtime.aggregate(rawInput(), {
|
||||
bucketCount: 2,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(first).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(workers[0]?.terminated).toBe(true);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
|
||||
const second = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
const request = workers[1]?.lastRequest;
|
||||
const peaks = aggregatePcmPeaks(rawInput(), { bucketCount: 2 });
|
||||
workers[1]?.emitMessage({
|
||||
type: 'result',
|
||||
requestId: request?.requestId,
|
||||
peaks,
|
||||
});
|
||||
await expect(second).resolves.toMatchObject({ bucketCount: 2 });
|
||||
});
|
||||
|
||||
it('supports explicit cancellation and rejects overlapping work', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
|
||||
expect(runtime.cancelActive()).toBe(true);
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(runtime.cancelActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back synchronously when workers are unavailable or blocked', async () => {
|
||||
const runtime = createWaveformRuntime({ workerFactory: null });
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).resolves.toMatchObject({ bucketCount: 2 });
|
||||
|
||||
const blockedRuntime = createWaveformRuntime({
|
||||
workerFactory: () => {
|
||||
throw new Error('CSP blocked worker');
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
blockedRuntime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).resolves.toMatchObject({ bucketCount: 2 });
|
||||
expect(blockedRuntime.mode).toBe('synchronous');
|
||||
});
|
||||
|
||||
it('tracks and can cancel synchronous fallback before it starts', async () => {
|
||||
const runtime = createWaveformRuntime({ workerFactory: null });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
|
||||
expect(runtime.isActive).toBe(true);
|
||||
const overlapping = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
expect(runtime.cancelActive()).toBe(true);
|
||||
await expect(overlapping).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('makes terminate permanent and cancels active work', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
runtime.terminate();
|
||||
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).rejects.toBeInstanceOf(WaveformRuntimeTerminatedError);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorker implements WaveformWorkerLike {
|
||||
readonly #messageListeners = new Set<
|
||||
(event: MessageEvent<unknown>) => void
|
||||
>();
|
||||
readonly #errorListeners = new Set<(event: Event) => void>();
|
||||
readonly #messageErrorListeners = new Set<(event: Event) => void>();
|
||||
lastRequest: WaveformWorkerAggregateRequest | undefined;
|
||||
terminated = false;
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.lastRequest = message as WaveformWorkerAggregateRequest;
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: 'message' | 'error' | 'messageerror',
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
this.#messageListeners.add(
|
||||
listener as (event: MessageEvent<unknown>) => void
|
||||
);
|
||||
} else if (type === 'error') {
|
||||
this.#errorListeners.add(listener as (event: Event) => void);
|
||||
} else {
|
||||
this.#messageErrorListeners.add(listener as (event: Event) => void);
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
type: 'message' | 'error' | 'messageerror',
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
this.#messageListeners.delete(
|
||||
listener as (event: MessageEvent<unknown>) => void
|
||||
);
|
||||
} else if (type === 'error') {
|
||||
this.#errorListeners.delete(listener as (event: Event) => void);
|
||||
} else {
|
||||
this.#messageErrorListeners.delete(listener as (event: Event) => void);
|
||||
}
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
emitMessage(data: unknown): void {
|
||||
const event = { data } as MessageEvent<unknown>;
|
||||
for (const listener of this.#messageListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawInput() {
|
||||
return {
|
||||
container: 'raw' as const,
|
||||
data: new Int16Array([-100, 100, -200, 200]),
|
||||
encoding: 's16le' as const,
|
||||
sampleRate: 8_000,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user