Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
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();
}
});
});