File size: 5,023 Bytes
fbf73ff
1493232
fbf73ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1493232
 
fbf73ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1493232
 
 
 
fbf73ff
 
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
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(<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')
  })
})