File size: 5,242 Bytes
900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d 8290bc9 e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 66404dc 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 900a32d e4daa3b 66404dc 900a32d |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
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(<NiiVueViewer {...defaultProps} />);
expect(document.querySelector("canvas")).toBeInTheDocument();
});
it("renders container with correct styling", () => {
render(<NiiVueViewer {...defaultProps} />);
const container = document.querySelector("canvas")?.parentElement;
expect(container).toHaveClass("bg-gray-900");
});
it("renders help text for controls", () => {
render(<NiiVueViewer {...defaultProps} />);
expect(screen.getByText(/scroll/i)).toBeInTheDocument();
expect(screen.getByText(/drag/i)).toBeInTheDocument();
});
it("attaches NiiVue to canvas on mount", () => {
render(<NiiVueViewer {...defaultProps} />);
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(<NiiVueViewer {...defaultProps} />);
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(<NiiVueViewer {...defaultProps} overlayUrl={overlayUrl} />);
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(<NiiVueViewer {...defaultProps} />);
unmount();
expect(mockCleanup).toHaveBeenCalled();
expect(mockLoseContext).toHaveBeenCalled();
});
it("sets canvas dimensions", () => {
render(<NiiVueViewer {...defaultProps} />);
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(<NiiVueViewer {...defaultProps} />);
// 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(<NiiVueViewer {...defaultProps} onError={onError} />);
// 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(
<NiiVueViewer
backgroundUrl="http://localhost/first.nii.gz"
onError={onError}
/>,
);
// Change URL - starts second load
rerender(
<NiiVueViewer
backgroundUrl="http://localhost/second.nii.gz"
onError={onError}
/>,
);
// Change URL again - makes second load stale
rerender(
<NiiVueViewer
backgroundUrl="http://localhost/third.nii.gz"
onError={onError}
/>,
);
// 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");
});
});
|