import { describe, expect, it, vi } from "@suaveplan/testing/runner"; import { act, renderHook } from "@testing-library/react"; import { useAnalyser } from "./use-analyser.js"; import type { AnalyserLike } from "./use-analyser.types.js"; function makeFakeAnalyser(bins = 16): AnalyserLike & { freqCalls: number; waveCalls: number; } { const state = { fftSize: 2048, frequencyBinCount: bins, minDecibels: -100, maxDecibels: -30, smoothingTimeConstant: 0.8, freqCalls: 0, waveCalls: 0, getByteFrequencyData(out: Uint8Array) { state.freqCalls += 1; out[0] = (state.freqCalls % 250) + 1; }, getByteTimeDomainData(out: Uint8Array) { state.waveCalls += 1; out[0] = ((state.waveCalls * 2) % 250) + 1; }, }; Object.defineProperty(state, "frequencyBinCount", { get: () => Math.max(1, Math.floor(state.fftSize / 2)), }); return state as unknown as AnalyserLike & { freqCalls: number; waveCalls: number; }; } describe("useAnalyser", () => { it("returns null analyser + empty buffers when no source provided", { timeout: 5000, }, () => { const { result, unmount } = renderHook(() => useAnalyser()); expect(result.current.analyser).toBeNull(); expect(result.current.frequency.length).toBe(0); expect(result.current.waveform.length).toBe(0); unmount(); }); it("polls the explicit analyser and writes into the shared buffer", { timeout: 5000, }, () => { vi.useFakeTimers(); try { const a = makeFakeAnalyser(8); const { result, unmount } = renderHook(() => useAnalyser({ analyser: a, intervalMs: 5, fftSize: 16 }) ); expect(result.current.analyser).toBe(a); expect(result.current.frequency.length).toBe(8); act(() => { vi.advanceTimersByTime(20); }); expect(a.freqCalls).toBeGreaterThan(0); expect(a.waveCalls).toBeGreaterThan(0); expect(result.current.frequency[0]).toBeGreaterThan(0); unmount(); } finally { vi.useRealTimers(); } }); it("applies fftSize/smoothing/min-max on the analyser", { timeout: 5000, }, () => { const a = makeFakeAnalyser(); renderHook(() => useAnalyser({ analyser: a, fftSize: 512, smoothingTimeConstant: 0.3, minDecibels: -120, maxDecibels: -20, }) ); expect(a.fftSize).toBe(512); expect(a.smoothingTimeConstant).toBe(0.3); expect(a.minDecibels).toBe(-120); expect(a.maxDecibels).toBe(-20); }); it("resolves via factory on tick, then stops re-resolving", { timeout: 5000, }, () => { vi.useFakeTimers(); try { let calls = 0; const a = makeFakeAnalyser(); const factory = () => { calls += 1; return calls >= 2 ? a : null; }; const { result } = renderHook(() => useAnalyser({ factory, intervalMs: 5 }) ); expect(result.current.analyser).toBeNull(); act(() => { vi.advanceTimersByTime(6); }); // First tick: factory returns null; resolved still null. act(() => { vi.advanceTimersByTime(6); }); expect(result.current.analyser).toBe(a); // Once resolved, factory should not be called again. const beforeCalls = calls; act(() => { vi.advanceTimersByTime(20); }); expect(calls).toBe(beforeCalls); } finally { vi.useRealTimers(); } }); it("swapping the analyser prop reconfigures buffers", { timeout: 5000, }, () => { const a1 = makeFakeAnalyser(8); const a2 = makeFakeAnalyser(16); const { result, rerender } = renderHook( ({ a }: { a: typeof a1 }) => useAnalyser({ analyser: a, fftSize: 32 }), { initialProps: { a: a1 } } ); expect(result.current.frequency.length).toBeGreaterThan(0); const first = result.current.frequency; rerender({ a: a2 }); // Buffer identity may change; instance must be a2. expect(result.current.analyser).toBe(a2); // Different fftSize bin count → different length. expect(result.current.frequency.length).toBe(Math.floor(32 / 2)); expect(first).not.toBe(result.current.frequency); }); it("setting analyser to null disables polling", { timeout: 5000 }, () => { vi.useFakeTimers(); try { const a = makeFakeAnalyser(); const { rerender } = renderHook( ({ x }: { x: typeof a | null }) => useAnalyser({ analyser: x, intervalMs: 5 }), { initialProps: { x: a as typeof a | null } } ); act(() => { vi.advanceTimersByTime(20); }); const before = a.freqCalls; rerender({ x: null }); act(() => { vi.advanceTimersByTime(20); }); // After null, no new poll calls land on `a`. expect(a.freqCalls).toBe(before); } finally { vi.useRealTimers(); } }); it("cleans up the interval on unmount", { timeout: 5000 }, () => { vi.useFakeTimers(); try { const a = makeFakeAnalyser(); const { unmount } = renderHook(() => useAnalyser({ analyser: a, intervalMs: 5 }) ); act(() => { vi.advanceTimersByTime(10); }); const before = a.freqCalls; unmount(); act(() => { vi.advanceTimersByTime(50); }); expect(a.freqCalls).toBe(before); } finally { vi.useRealTimers(); } }); it("factory returning null indefinitely keeps analyser null", { timeout: 5000, }, () => { vi.useFakeTimers(); try { const { result } = renderHook(() => useAnalyser({ factory: () => null, intervalMs: 5 }) ); act(() => { vi.advanceTimersByTime(30); }); expect(result.current.analyser).toBeNull(); } finally { vi.useRealTimers(); } }); it("no factory, no analyser → tick is a no-op", { timeout: 5000 }, () => { vi.useFakeTimers(); try { const { result } = renderHook(() => useAnalyser({ intervalMs: 5 })); act(() => { vi.advanceTimersByTime(20); }); expect(result.current.analyser).toBeNull(); } finally { vi.useRealTimers(); } }); it("changing fftSize after mount reallocates buffers", { timeout: 5000, }, () => { const a = makeFakeAnalyser(); const { result, rerender } = renderHook( ({ size }: { size: number }) => useAnalyser({ analyser: a, fftSize: size }), { initialProps: { size: 32 } } ); expect(result.current.frequency.length).toBe(16); rerender({ size: 64 }); expect(result.current.frequency.length).toBe(32); expect(a.fftSize).toBe(64); }); it("intervalMs <= 0 is clamped to 1", { timeout: 5000 }, () => { vi.useFakeTimers(); try { const a = makeFakeAnalyser(); renderHook(() => useAnalyser({ analyser: a, intervalMs: 0 })); act(() => { vi.advanceTimersByTime(5); }); expect(a.freqCalls).toBeGreaterThan(0); } finally { vi.useRealTimers(); } }); });