# Spec 37.3: Interactive Components **Status**: READY FOR IMPLEMENTATION **Phase**: 3 of 5 **Depends On**: Spec 37.2 (API Layer) **Goal**: TDD implementation of CaseSelector and NiiVueViewer components --- ## Deliverables By the end of this phase, you will have: 1. `CaseSelector` dropdown that fetches and displays cases 2. `NiiVueViewer` component for 3D medical image viewing 3. Loading and error states for both components 4. WebGL mocking for NiiVue tests --- ## Component 1: CaseSelector ### Test First Create `src/components/__tests__/CaseSelector.test.tsx`: ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { server } from '../../mocks/server' import { errorHandlers } from '../../mocks/handlers' import { CaseSelector } from '../CaseSelector' describe('CaseSelector', () => { const mockOnSelectCase = vi.fn() beforeEach(() => { mockOnSelectCase.mockClear() }) it('shows loading state initially', () => { render( ) expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('renders select after loading', async () => { render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) }) it('displays all cases as options', async () => { render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) expect(screen.getByRole('option', { name: /sub-stroke0001/i })).toBeInTheDocument() expect(screen.getByRole('option', { name: /sub-stroke0002/i })).toBeInTheDocument() expect(screen.getByRole('option', { name: /sub-stroke0003/i })).toBeInTheDocument() }) it('has placeholder option', async () => { render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) expect(screen.getByRole('option', { name: /choose a case/i })).toBeInTheDocument() }) it('calls onSelectCase when case selected', async () => { const user = userEvent.setup() render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') expect(mockOnSelectCase).toHaveBeenCalledWith('sub-stroke0001') }) it('shows selected case value', async () => { render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002') }) }) it('shows error state on API failure', async () => { server.use(errorHandlers.casesServerError) render( ) await waitFor(() => { expect(screen.getByText(/failed to load/i)).toBeInTheDocument() }) }) it('applies correct styling', async () => { render( ) await waitFor(() => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) const container = screen.getByRole('combobox').closest('div') expect(container).toHaveClass('bg-gray-800') }) }) ``` ### Implementation Create `src/components/CaseSelector.tsx`: ```typescript import { useEffect, useState } from 'react' import { apiClient } from '../api/client' interface CaseSelectorProps { selectedCase: string | null onSelectCase: (caseId: string) => void } export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) { const [cases, setCases] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchCases = async () => { try { const data = await apiClient.getCases() setCases(data.cases) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' setError(`Failed to load cases: ${message}`) } finally { setIsLoading(false) } } fetchCases() }, []) if (isLoading) { return (

Loading cases...

) } if (error) { return (

{error}

) } return (
) } ``` ### Verify ```bash npm test -- CaseSelector # Expected: 9 tests passing ``` --- ## Component 2: NiiVueViewer ### WebGL Mock Setup Update `src/test/setup.ts` to add WebGL mocking: ```typescript import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' import { afterEach, beforeAll, afterAll, vi } from 'vitest' import { server } from '../mocks/server' beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => { cleanup() server.resetHandlers() }) afterAll(() => server.close()) // Mock ResizeObserver global.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} disconnect() {} } // Mock WebGL2 context for NiiVue // NiiVue requires specific extensions for float textures (overlays) // See: https://github.com/niivue/niivue#browser-requirements const mockExtensions: Record = { // Required for float textures (overlay rendering) EXT_color_buffer_float: {}, OES_texture_float_linear: {}, // Required for WebGL context management WEBGL_lose_context: { loseContext: vi.fn(), restoreContext: vi.fn(), }, // Optional but commonly requested EXT_texture_filter_anisotropic: { TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe, MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff, }, WEBGL_debug_renderer_info: { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246, }, } const mockWebGL2Context = { canvas: null as HTMLCanvasElement | null, drawingBufferWidth: 640, drawingBufferHeight: 480, createShader: vi.fn(() => ({})), shaderSource: vi.fn(), compileShader: vi.fn(), getShaderParameter: vi.fn(() => true), getShaderInfoLog: vi.fn(() => ''), createProgram: vi.fn(() => ({})), attachShader: vi.fn(), linkProgram: vi.fn(), getProgramParameter: vi.fn(() => true), getProgramInfoLog: vi.fn(() => ''), useProgram: vi.fn(), getAttribLocation: vi.fn(() => 0), getUniformLocation: vi.fn(() => ({})), createBuffer: vi.fn(() => ({})), bindBuffer: vi.fn(), bufferData: vi.fn(), enableVertexAttribArray: vi.fn(), vertexAttribPointer: vi.fn(), createTexture: vi.fn(() => ({})), bindTexture: vi.fn(), texParameteri: vi.fn(), texParameterf: vi.fn(), texImage2D: vi.fn(), texImage3D: vi.fn(), texStorage2D: vi.fn(), texStorage3D: vi.fn(), texSubImage2D: vi.fn(), texSubImage3D: vi.fn(), activeTexture: vi.fn(), generateMipmap: vi.fn(), uniform1i: vi.fn(), uniform1f: vi.fn(), uniform2f: vi.fn(), uniform2fv: vi.fn(), uniform3f: vi.fn(), uniform3fv: vi.fn(), uniform4f: vi.fn(), uniform4fv: vi.fn(), uniformMatrix4fv: vi.fn(), viewport: vi.fn(), scissor: vi.fn(), clear: vi.fn(), clearColor: vi.fn(), clearDepth: vi.fn(), enable: vi.fn(), disable: vi.fn(), blendFunc: vi.fn(), blendFuncSeparate: vi.fn(), depthFunc: vi.fn(), depthMask: vi.fn(), cullFace: vi.fn(), drawArrays: vi.fn(), drawElements: vi.fn(), // CRITICAL: Return stub extensions for NiiVue float texture support getExtension: vi.fn((name: string) => mockExtensions[name] || null), getParameter: vi.fn((pname: number) => { // Return reasonable defaults for common parameter queries if (pname === 0x0d33) return 16384 // MAX_TEXTURE_SIZE if (pname === 0x8073) return 2048 // MAX_3D_TEXTURE_SIZE if (pname === 0x851c) return 16 // MAX_TEXTURE_IMAGE_UNITS return 0 }), getSupportedExtensions: vi.fn(() => Object.keys(mockExtensions)), pixelStorei: vi.fn(), readPixels: vi.fn(), createFramebuffer: vi.fn(() => ({})), bindFramebuffer: vi.fn(), framebufferTexture2D: vi.fn(), checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE createRenderbuffer: vi.fn(() => ({})), bindRenderbuffer: vi.fn(), renderbufferStorage: vi.fn(), framebufferRenderbuffer: vi.fn(), deleteTexture: vi.fn(), deleteBuffer: vi.fn(), deleteProgram: vi.fn(), deleteShader: vi.fn(), deleteFramebuffer: vi.fn(), deleteRenderbuffer: vi.fn(), createVertexArray: vi.fn(() => ({})), bindVertexArray: vi.fn(), deleteVertexArray: vi.fn(), flush: vi.fn(), finish: vi.fn(), isContextLost: vi.fn(() => false), } HTMLCanvasElement.prototype.getContext = function ( contextType: string ): RenderingContext | null { if (contextType === 'webgl2' || contextType === 'webgl') { return { ...mockWebGL2Context, canvas: this, } as unknown as WebGL2RenderingContext } return null } ``` ### Test First Create `src/components/__tests__/NiiVueViewer.test.tsx`: ```typescript import { describe, it, expect, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import { NiiVueViewer } from '../NiiVueViewer' // Mock the NiiVue module since it requires actual WebGL vi.mock('@niivue/niivue', () => ({ Niivue: vi.fn().mockImplementation(() => ({ attachToCanvas: vi.fn(), loadVolumes: vi.fn().mockResolvedValue(undefined), setSliceType: vi.fn(), cleanup: vi.fn(), // NiiVue's cleanup() releases event listeners/observers gl: { getExtension: vi.fn(() => ({ loseContext: vi.fn() })), }, opts: {}, })), })) describe('NiiVueViewer', () => { const defaultProps = { backgroundUrl: 'http://localhost:7860/files/dwi.nii.gz', } 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('initializes NiiVue with background volume', async () => { const { Niivue } = await import('@niivue/niivue') render() expect(Niivue).toHaveBeenCalled() }) it('loads overlay when provided', async () => { const { Niivue } = await import('@niivue/niivue') const mockInstance = { attachToCanvas: vi.fn(), loadVolumes: vi.fn().mockResolvedValue(undefined), cleanup: vi.fn(), gl: { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) }, opts: {}, } ;(Niivue as unknown as ReturnType).mockImplementation( () => mockInstance ) render( ) // Wait for useEffect to run await waitFor(() => { expect(mockInstance.loadVolumes).toHaveBeenCalled() }) const loadVolumesCall = mockInstance.loadVolumes.mock.calls[0][0] expect(loadVolumesCall).toHaveLength(2) expect(loadVolumesCall[1].url).toContain('prediction.nii.gz') }) it('sets canvas dimensions', () => { render() const canvas = document.querySelector('canvas') expect(canvas).toHaveClass('w-full', 'h-[500px]') }) }) ``` ### Implementation Create `src/components/NiiVueViewer.tsx`: ```typescript import { useRef, useEffect } from 'react' import { Niivue } from '@niivue/niivue' interface NiiVueViewerProps { backgroundUrl: string overlayUrl?: string } export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) { const canvasRef = useRef(null) const nvRef = useRef(null) useEffect(() => { if (!canvasRef.current) return // Only instantiate NiiVue once; reuse for volume reloads let nv = nvRef.current if (!nv) { nv = new Niivue({ backColor: [0.05, 0.05, 0.05, 1], show3Dcrosshair: true, crosshairColor: [1, 0, 0, 0.5], }) nv.attachToCanvas(canvasRef.current) nvRef.current = nv } // Build volumes array - always reload when URLs change const volumes: Array<{ url: string; colormap: string; opacity: number }> = [ { url: backgroundUrl, colormap: 'gray', opacity: 1 }, ] if (overlayUrl) { volumes.push({ url: overlayUrl, colormap: 'red', opacity: 0.5, }) } // Load volumes (async but we don't await - just fire off) void nv.loadVolumes(volumes) // Cleanup on unmount - CRITICAL: Release WebGL context // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup, // navigating between results will exhaust contexts and break the viewer. return () => { if (nvRef.current) { // Capture gl BEFORE cleanup (cleanup may null internal state) const gl = nvRef.current.gl try { // NiiVue's cleanup() releases event listeners and observers // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup nvRef.current.cleanup() // Force WebGL context loss to free GPU memory immediately if (gl) { const ext = gl.getExtension('WEBGL_lose_context') ext?.loseContext() } } catch { // Ignore cleanup errors } nvRef.current = null } } }, [backgroundUrl, overlayUrl]) return (
Scroll: Navigate slices Drag: Adjust contrast Right-click: Pan
) } ``` ### Verify ```bash npm test -- NiiVueViewer # Expected: 6 tests passing ``` --- ## Update Component Index Update `src/components/index.ts`: ```typescript export { Layout } from './Layout' export { MetricsPanel } from './MetricsPanel' export { CaseSelector } from './CaseSelector' export { NiiVueViewer } from './NiiVueViewer' ``` --- ## Visual Verification Update `src/App.tsx` to preview all components: ```typescript import { useState } from 'react' import { Layout } from './components/Layout' import { CaseSelector } from './components/CaseSelector' import { MetricsPanel } from './components/MetricsPanel' import { NiiVueViewer } from './components/NiiVueViewer' const mockMetrics = { caseId: 'sub-stroke0001', diceScore: 0.847, volumeMl: 15.32, elapsedSeconds: 12.5, } // Demo NIfTI file from NiiVue examples const DEMO_NIFTI = 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz' function App() { const [selectedCase, setSelectedCase] = useState(null) return (
) } export default App ``` ```bash npm run dev # Open http://localhost:5173 # Verify: # - CaseSelector loads and shows cases # - NiiVue viewer renders 3D brain # - MetricsPanel displays correctly ``` --- ## Verification Checklist ```bash npm test # Expected: ~35+ tests passing ``` - [ ] CaseSelector shows loading state - [ ] CaseSelector fetches and displays cases - [ ] CaseSelector calls onSelectCase on selection - [ ] CaseSelector shows error state on API failure - [ ] NiiVueViewer renders canvas - [ ] NiiVueViewer initializes NiiVue instance - [ ] NiiVueViewer loads overlay when provided - [ ] Visual: All components render correctly in browser --- ## File Structure After This Phase ```text frontend/src/ ├── components/ │ ├── __tests__/ │ │ ├── Layout.test.tsx │ │ ├── MetricsPanel.test.tsx │ │ ├── CaseSelector.test.tsx │ │ └── NiiVueViewer.test.tsx │ ├── Layout.tsx │ ├── MetricsPanel.tsx │ ├── CaseSelector.tsx │ ├── NiiVueViewer.tsx │ └── index.ts ├── api/ ├── hooks/ ├── types/ ├── test/ │ └── setup.ts (updated with WebGL mocks) ├── mocks/ └── App.tsx (updated) ``` --- ## Next Phase Once verification passes, proceed to **Spec 37.4: App Integration**