fix(arch): Config SSOT, reproducible builds, and data pipeline documentation (#41)
ba32591
unverified
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:
CaseSelectordropdown that fetches and displays casesNiiVueViewercomponent for 3D medical image viewing- Loading and error states for both components
- WebGL mocking for NiiVue tests
Component 1: CaseSelector
Test First
Create src/components/__tests__/CaseSelector.test.tsx:
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(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('renders select after loading', async () => {
render(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
})
it('displays all cases as options', async () => {
render(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
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(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
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(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
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(
<CaseSelector
selectedCase="sub-stroke0002"
onSelectCase={mockOnSelectCase}
/>
)
await waitFor(() => {
expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002')
})
})
it('shows error state on API failure', async () => {
server.use(errorHandlers.casesServerError)
render(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
})
})
it('applies correct styling', async () => {
render(
<CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
)
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:
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<string[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-gray-400">Loading cases...</p>
</div>
)
}
if (error) {
return (
<div className="bg-red-900/50 rounded-lg p-4">
<p className="text-red-300">{error}</p>
</div>
)
}
return (
<div className="bg-gray-800 rounded-lg p-4">
<label className="block text-sm font-medium mb-2">Select Case</label>
<select
value={selectedCase || ''}
onChange={(e) => onSelectCase(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Choose a case...</option>
{cases.map((caseId) => (
<option key={caseId} value={caseId}>
{caseId}
</option>
))}
</select>
</div>
)
}
Verify
npm test -- CaseSelector
# Expected: 9 tests passing
Component 2: NiiVueViewer
WebGL Mock Setup
Update src/test/setup.ts to add WebGL mocking:
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<string, object> = {
// 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:
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(<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('initializes NiiVue with background volume', async () => {
const { Niivue } = await import('@niivue/niivue')
render(<NiiVueViewer {...defaultProps} />)
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<typeof vi.fn>).mockImplementation(
() => mockInstance
)
render(
<NiiVueViewer
{...defaultProps}
overlayUrl="http://localhost:7860/files/prediction.nii.gz"
/>
)
// 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(<NiiVueViewer {...defaultProps} />)
const canvas = document.querySelector('canvas')
expect(canvas).toHaveClass('w-full', 'h-[500px]')
})
})
Implementation
Create src/components/NiiVueViewer.tsx:
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<HTMLCanvasElement>(null)
const nvRef = useRef<Niivue | null>(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 (
<div className="bg-gray-900 rounded-lg p-2">
<canvas ref={canvasRef} className="w-full h-[500px] rounded" />
<div className="flex gap-4 mt-2 text-xs text-gray-400">
<span>Scroll: Navigate slices</span>
<span>Drag: Adjust contrast</span>
<span>Right-click: Pan</span>
</div>
</div>
)
}
Verify
npm test -- NiiVueViewer
# Expected: 6 tests passing
Update Component Index
Update src/components/index.ts:
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:
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<string | null>(null)
return (
<Layout>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-4">
<CaseSelector
selectedCase={selectedCase}
onSelectCase={setSelectedCase}
/>
<MetricsPanel metrics={mockMetrics} />
</div>
<div className="lg:col-span-2">
<NiiVueViewer backgroundUrl={DEMO_NIFTI} />
</div>
</div>
</Layout>
)
}
export default App
npm run dev
# Open http://localhost:5173
# Verify:
# - CaseSelector loads and shows cases
# - NiiVue viewer renders 3D brain
# - MetricsPanel displays correctly
Verification Checklist
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
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