import type { SidechainPublisher } from "@suaveplan/audio-fx-plugin-base"; import { afterEach, beforeAll, describe, expect, it, } from "@suaveplan/testing/runner"; import { cleanup, waitFor } from "@testing-library/react"; import { useEffect } from "react"; import { useProviderStateOrThrow } from "../../hooks/use-provider-state.js"; import { renderWithProvider } from "../../test-utils.js"; import { AudioVisualizer, drawLevel, drawSpectrum, drawWaveform, } from "./audio-visualizer.js"; afterEach(() => cleanup()); interface FakeCtxCalls { fillRectCalls: number; strokeCalls: number; beginPathCalls: number; moveToCalls: number; lineToCalls: number; } function makeFakeCtx(): { ctx: CanvasRenderingContext2D; calls: FakeCtxCalls; } { const calls: FakeCtxCalls = { fillRectCalls: 0, strokeCalls: 0, beginPathCalls: 0, moveToCalls: 0, lineToCalls: 0, }; const ctx = { fillStyle: "", strokeStyle: "", fillRect: () => { calls.fillRectCalls++; }, stroke: () => { calls.strokeCalls++; }, beginPath: () => { calls.beginPathCalls++; }, moveTo: () => { calls.moveToCalls++; }, lineTo: () => { calls.lineToCalls++; }, } as unknown as CanvasRenderingContext2D; return { ctx, calls }; } beforeAll(() => { // happy-dom returns null from canvas.getContext by default; we // install a minimal 2D fake on the prototype so the drawing-effect // path runs end-to-end. Stash the original so other tests aren't // affected after the file ends. const proto = HTMLCanvasElement.prototype as unknown as { getContext: (id: string) => unknown; }; proto.getContext = (_id: string) => makeFakeCtx().ctx; }); function Publisher({ busName, channels, amplitude, }: { busName: string; channels: number; amplitude: number; }) { const { buses } = useProviderStateOrThrow(); useEffect(() => { const p: SidechainPublisher = buses.publish(busName, channels); const id = setInterval(() => { const samples: Float32Array[] = []; for (let c = 0; c < channels; c++) { const arr = new Float32Array(256); for (let i = 0; i < arr.length; i++) { arr[i] = amplitude * Math.sin((i / arr.length) * Math.PI * 2); } samples.push(arr); } p.write(samples); }, 10); return () => { clearInterval(id); p.close(); }; }, [buses, busName, channels, amplitude]); return null; } describe("", () => { it("renders a canvas bound to useAnalyser", { timeout: 30000, }, async () => { const { container } = renderWithProvider( <> ); await waitFor(() => expect(container.querySelector("canvas")).not.toBeNull() ); const canvas = container.querySelector("canvas") as HTMLCanvasElement; expect(canvas.getAttribute("data-mode")).toBe("waveform"); expect(canvas.getAttribute("data-bus")).toBe("master"); expect(canvas.width).toBe(320); expect(canvas.height).toBe(80); // Wait for several ticks so the drawWaveform call site fires await new Promise((r) => setTimeout(r, 80)); }); it("renders spectrum mode", { timeout: 30000 }, async () => { const { container } = renderWithProvider( <> ); await waitFor(() => expect( container.querySelector("canvas[data-mode='spectrum']") ).not.toBeNull() ); }); it("renders level mode", { timeout: 30000 }, async () => { const { container } = renderWithProvider( <> ); await waitFor(() => expect( container.querySelector("canvas[data-mode='level']") ).not.toBeNull() ); }); it("falls back to background draw when no publisher", { timeout: 30000, }, async () => { const { container } = renderWithProvider( ); await waitFor(() => expect(container.querySelector("canvas")).not.toBeNull() ); }); it("custom dimensions, color, and bus name", { timeout: 30000, }, async () => { const { container } = renderWithProvider( ); await waitFor(() => expect( container.querySelector("canvas[data-bus='aux']") ).not.toBeNull() ); const canvas = container.querySelector("canvas") as HTMLCanvasElement; expect(canvas.width).toBe(120); expect(canvas.height).toBe(40); }); it("clamps sampleRateHz<=0", { timeout: 30000 }, async () => { const { container } = renderWithProvider( ); await waitFor(() => expect(container.querySelector("canvas")).not.toBeNull() ); }); it("forwards className", { timeout: 30000 }, async () => { const { container } = renderWithProvider( ); await waitFor(() => expect( container.querySelector(".rax-audio-visualizer.custom-vis") ).not.toBeNull() ); }); it("getContext returning null is handled gracefully", { timeout: 30000, }, async () => { const proto = HTMLCanvasElement.prototype as unknown as { getContext: (id: string) => unknown; }; const prev = proto.getContext; proto.getContext = () => null; try { const { container } = renderWithProvider(); await waitFor(() => expect(container.querySelector("canvas")).not.toBeNull() ); } finally { proto.getContext = prev; } }); }); describe("drawing primitives", () => { it("drawWaveform draws one line per sample", { timeout: 5000 }, () => { const { ctx, calls } = makeFakeCtx(); const data = new Float32Array(8); for (let i = 0; i < 8; i++) data[i] = i / 8; drawWaveform(ctx, data, 100, 50); expect(calls.beginPathCalls).toBe(1); expect(calls.moveToCalls).toBe(1); expect(calls.lineToCalls).toBe(7); expect(calls.strokeCalls).toBe(1); }); it("drawSpectrum draws one bar per bin", { timeout: 5000 }, () => { const { ctx, calls } = makeFakeCtx(); const data = new Float32Array(64); for (let i = 0; i < 64; i++) data[i] = 0.5; drawSpectrum(ctx, data, 100, 50); expect(calls.fillRectCalls).toBe(32); }); it("drawSpectrum handles bin overflow", { timeout: 5000 }, () => { const { ctx, calls } = makeFakeCtx(); // 30 samples for 32 bins => last 2 bins exercise the inner break. const data = new Float32Array(30); for (let i = 0; i < 30; i++) data[i] = 1; drawSpectrum(ctx, data, 100, 50); expect(calls.fillRectCalls).toBe(32); }); it("drawLevel draws one bar sized to peak", { timeout: 5000 }, () => { const { ctx, calls } = makeFakeCtx(); const data = new Float32Array([0.1, -0.5, 0.3]); drawLevel(ctx, data, 100, 10); expect(calls.fillRectCalls).toBe(1); }); it("drawWaveform on an empty array degrades gracefully", { timeout: 5000, }, () => { const { ctx, calls } = makeFakeCtx(); drawWaveform(ctx, new Float32Array(0), 100, 50); expect(calls.beginPathCalls).toBe(1); expect(calls.moveToCalls).toBe(0); expect(calls.lineToCalls).toBe(0); expect(calls.strokeCalls).toBe(1); }); });