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(); 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(); 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(); 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(); 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(); 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; suspend?: Deferred; 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 { promise: Promise; resolve(value: T | PromiseLike): void; reject(reason?: unknown): void; } function deferred(): Deferred { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((onResolve, onReject) => { resolve = onResolve; reject = onReject; }); return { promise, resolve, reject }; }