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', () => { 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') }) })