247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|