import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { NiiVueViewer } from "../NiiVueViewer"; // Store mock function references so tests can verify calls const mockLoadVolumes = vi.fn().mockResolvedValue(undefined); const mockCleanup = vi.fn(); const mockAttachToCanvas = vi.fn(); const mockLoseContext = vi.fn(); // Mock the NiiVue module since it requires actual WebGL vi.mock("@niivue/niivue", () => ({ Niivue: class MockNiivue { attachToCanvas = mockAttachToCanvas; loadVolumes = mockLoadVolumes; setSliceType = vi.fn(); cleanup = mockCleanup; gl = { getExtension: vi.fn(() => ({ loseContext: mockLoseContext })), }; opts = {}; }, })); describe("NiiVueViewer", () => { // Note: URLs are simplified for component testing (actual API uses /files/{jobId}/{caseId}/) const defaultProps = { backgroundUrl: "http://localhost:7860/files/dwi.nii.gz", }; beforeEach(() => { vi.clearAllMocks(); }); it("renders canvas element", () => { render(); expect(document.querySelector("canvas")).toBeInTheDocument(); }); it("renders container with correct styling", () => { render(); const container = document.querySelector("canvas")?.parentElement; expect(container).toHaveClass("bg-gray-900"); }); it("renders help text for controls", () => { render(); expect(screen.getByText(/scroll/i)).toBeInTheDocument(); expect(screen.getByText(/drag/i)).toBeInTheDocument(); }); it("attaches NiiVue to canvas on mount", () => { render(); expect(mockAttachToCanvas).toHaveBeenCalled(); // Verify it was called with a canvas element const arg = mockAttachToCanvas.mock.calls[0][0]; expect(arg).toBeInstanceOf(HTMLCanvasElement); }); it("loads background volume on mount", () => { render(); expect(mockLoadVolumes).toHaveBeenCalledWith([ { url: defaultProps.backgroundUrl, colormap: "gray", opacity: 1 }, ]); }); it("loads both background and overlay when overlayUrl provided", () => { const overlayUrl = "http://localhost:7860/files/prediction.nii.gz"; render(); expect(mockLoadVolumes).toHaveBeenCalledWith([ { url: defaultProps.backgroundUrl, colormap: "gray", opacity: 1 }, { url: overlayUrl, colormap: "red", opacity: 0.5 }, ]); }); it("calls cleanup on unmount", () => { const { unmount } = render(); unmount(); expect(mockCleanup).toHaveBeenCalled(); expect(mockLoseContext).toHaveBeenCalled(); }); it("sets canvas dimensions", () => { render(); const canvas = document.querySelector("canvas"); expect(canvas).toHaveClass("w-full", "h-[500px]"); }); it("displays error when volume loading fails", async () => { const errorMessage = "Network error loading volume"; mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage)); render(); // Wait for error to be displayed const errorElement = await screen.findByText(/failed to load volume/i); expect(errorElement).toBeInTheDocument(); expect(errorElement).toHaveTextContent(errorMessage); }); it("calls onError callback when volume loading fails", async () => { const errorMessage = "Network error"; const onError = vi.fn(); mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage)); render(); // Wait for error callback to be invoked (use RTL's waitFor, not vi.waitFor) await waitFor(() => { expect(onError).toHaveBeenCalledWith(errorMessage); }); }); it("ignores errors from stale loads after URL change", async () => { const onError = vi.fn(); // First load succeeds, second load fails slowly let rejectSecondLoad: (error: Error) => void; mockLoadVolumes.mockResolvedValueOnce(undefined).mockImplementationOnce( () => new Promise((_, reject) => { rejectSecondLoad = reject; }), ); const { rerender } = render( , ); // Change URL - starts second load rerender( , ); // Change URL again - makes second load stale rerender( , ); // Now reject the second load (stale) rejectSecondLoad!(new Error("Stale load error")); // Flush async work (let rejection be processed) before asserting // Using waitFor with negative assertions is flaky - it passes immediately await new Promise((resolve) => setTimeout(resolve, 0)); expect(onError).not.toHaveBeenCalledWith("Stale load error"); }); });