Files
Portfolio/tests/unit/audio-lifecycle.test.ts

287 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { createAudioController } from '@/audio/create-audio-controller';
import type {
AudioGraph,
AudioGraphFactory,
AudioJourneyState,
} from '@/audio/types';
describe('audio controller lifecycle', () => {
it('constructs no graph before activation and only one graph for rapid repeated activation', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
expect(harness.constructed).toBe(0);
const first = controller.startFromGesture();
const second = controller.startFromGesture();
expect(harness.constructed).toBe(1);
await Promise.all([first, second]);
expect(harness.constructed).toBe(1);
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
expect(harness.graph.connectOutput).toHaveBeenCalledOnce();
});
it('applies clamped volume and mute changes made while starting without changing lifecycle intent', async () => {
const resume = deferred<void>();
const harness = createHarness({ resume });
const controller = createAudioController({ createGraph: harness.createGraph, initialVolume: 3 });
const starting = controller.startFromGesture();
controller.setVolume(-1);
controller.setMuted(true);
expect(controller.snapshot()).toEqual({ kind: 'starting' });
resume.resolve();
await starting;
expect(controller.snapshot()).toEqual({ kind: 'running', muted: true, volume: 0 });
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
});
it('retains one initialization when hidden then visible before starting completes', async () => {
const resume = deferred<void>();
const harness = createHarness({ resume });
const controller = createAudioController({ createGraph: harness.createGraph });
const starting = controller.startFromGesture();
controller.setVisible(false);
controller.setVisible(true);
resume.resolve();
await starting;
expect(controller.snapshot().kind).toBe('running');
expect(harness.constructed).toBe(1);
expect(harness.graph.suspend).not.toHaveBeenCalled();
expect(harness.graph.connectOutput).toHaveBeenCalledOnce();
});
it('finishes an interrupted initialization only into safely suspended user-required state', async () => {
const resume = deferred<void>();
const harness = createHarness({ resume });
const controller = createAudioController({ createGraph: harness.createGraph });
const starting = controller.startFromGesture();
controller.noteInterruption();
resume.resolve();
await starting;
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
expect(harness.graph.suspend).toHaveBeenCalledOnce();
});
it('automatically suspends and resumes an intact graph across visibility and persisted BFCache', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
await controller.pageHide(true);
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: false });
expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce();
await controller.pageShow(true);
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
expect(harness.constructed).toBe(1);
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
expect(harness.graph.connectOutput).toHaveBeenCalledTimes(2);
});
it('honors a visible event that arrives before a running suspension completes', async () => {
const suspension = deferred<void>();
const harness = createHarness({ suspend: suspension });
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
const hiding = controller.setVisible(false);
const showing = controller.setVisible(true);
suspension.resolve();
await Promise.all([hiding, showing]);
await controller.whenSettled();
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
expect(harness.graph.suspend).toHaveBeenCalledOnce();
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
expect(harness.constructed).toBe(1);
});
it('does not convert an interruption into automatic resume on later visibility events', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
await controller.noteInterruption();
await controller.setVisible(false);
await controller.setVisible(true);
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
expect(harness.graph.resume).toHaveBeenCalledOnce();
await controller.resumeFromGesture();
expect(controller.snapshot().kind).toBe('running');
expect(harness.constructed).toBe(1);
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
});
it('enters a recoverable visible failure when the first explicit start rejects', async () => {
const harness = createHarness({ resumeRejects: true });
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' });
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce();
expect(harness.graph.close).toHaveBeenCalledOnce();
});
it('keeps an existing graph suspended and user-recoverable when automatic resume rejects', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
await controller.setVisible(false);
harness.rejectNextResume();
await controller.setVisible(true);
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
expect(harness.graph.close).not.toHaveBeenCalled();
expect(harness.constructed).toBe(1);
});
it('forces silence, closes once, and fails when suspension cannot be guaranteed', async () => {
const harness = createHarness({ suspendRejects: true });
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.startFromGesture();
await controller.setVisible(false);
await controller.setVisible(true);
expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' });
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
expect(harness.graph.disconnectOutput).toHaveBeenCalled();
expect(harness.graph.close).toHaveBeenCalledOnce();
});
it('invalidates a pending start and closes exactly once on final pagehide or repeated disposal', async () => {
const resume = deferred<void>();
const harness = createHarness({ resume });
const controller = createAudioController({ createGraph: harness.createGraph });
const starting = controller.startFromGesture();
const closing = controller.pageHide(false);
controller.dispose();
expect(harness.graph.cancelPending).toHaveBeenCalledOnce();
resume.resolve();
await Promise.all([starting, closing]);
await controller.whenSettled();
expect(controller.snapshot()).toEqual({ kind: 'disposed' });
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
expect(harness.graph.close).toHaveBeenCalledOnce();
expect(harness.graph.cancelPending).toHaveBeenCalledTimes(2);
});
it('ignores lifecycle events before activation and after failure without creating another graph', async () => {
const harness = createHarness({ resumeRejects: true });
const controller = createAudioController({ createGraph: harness.createGraph });
await controller.setVisible(false);
await controller.pageShow(true);
expect(harness.constructed).toBe(0);
await controller.startFromGesture();
await controller.setVisible(true);
await controller.pageShow(true);
expect(harness.constructed).toBe(1);
expect(controller.snapshot().kind).toBe('failed');
});
it('stores the latest semantic state before activation and forwards raw clamped updates', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
controller.setJourneyState({ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 2 });
expect(harness.constructed).toBe(0);
await controller.startFromGesture();
expect(harness.initialStates).toEqual([
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 },
]);
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
atStart: false,
bias: 'shadow',
entryPhrase: 2,
intensity: 0.73,
});
});
});
interface HarnessOptions {
resume?: Deferred<void>;
suspend?: Deferred<void>;
resumeRejects?: boolean;
suspendRejects?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
let rejectNextResume = options.resumeRejects === true;
const stateListeners = new Set<(state: string) => void>();
const graph = {
resume: vi.fn(async () => {
if (options.resume) return options.resume.promise;
if (rejectNextResume) {
rejectNextResume = false;
throw new DOMException('blocked', 'NotAllowedError');
}
}),
suspend: vi.fn(async () => {
if (options.suspend) return options.suspend.promise;
if (options.suspendRejects) throw new Error('suspend failed');
}),
close: vi.fn(async () => undefined),
connectOutput: vi.fn(),
disconnectOutput: vi.fn(),
setOutputGain: vi.fn(),
setJourneyState: vi.fn(),
cancelPending: vi.fn(),
onContextStateChange: vi.fn((listener) => {
stateListeners.add(listener);
return () => stateListeners.delete(listener);
}),
} satisfies AudioGraph;
let constructed = 0;
const initialStates: AudioJourneyState[] = [];
const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => {
constructed += 1;
if (initialState) initialStates.push(initialState);
return graph;
};
return {
graph,
createGraph,
initialStates,
get constructed() { return constructed; },
rejectNextResume() { rejectNextResume = true; },
emitState(state: string) { for (const listener of stateListeners) listener(state); },
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}