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");
});
});