VibecoderMcSwaggins commited on
Commit
fbf73ff
·
0 Parent(s):

feat(frontend): React + Vite + NiiVue frontend (replaces Gradio) (#32)

Browse files

## Summary
React + Vite + NiiVue frontend replacing Gradio

### Key Changes
- React 19 + Vite 7 + TypeScript strict mode
- NiiVue 0.65.0 for medical image visualization
- 58 unit tests (Vitest) + 8 E2E tests (Playwright)
- 78%+ branch coverage
- CI pipeline with lint, typecheck, test, build jobs

### CodeRabbit Review Fixes
- Split NiiVue into two effects (mount vs URL changes)
- Add AbortController + request token to useSegmentation
- Add isCurrent cleanup flag to NiiVueViewer
- Remove test-only types from tsconfig.app.json
- Update spec docs with correct versions

.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ VITE_API_URL=http://localhost:7860
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Test output
27
+ coverage
28
+ playwright-report
29
+ test-results
README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
e2e/error-handling.spec.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test as base, expect } from '@playwright/test'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ // Error tests need to override the default mocks, so use base test
5
+ const test = base
6
+
7
+ test.describe('Error Handling', () => {
8
+ test('shows error when API fails', async ({ page }) => {
9
+ // Mock cases API (needed for page to load)
10
+ await page.route('**/api/cases', (route) => {
11
+ route.fulfill({
12
+ status: 200,
13
+ contentType: 'application/json',
14
+ body: JSON.stringify({ cases: ['sub-stroke0001'] }),
15
+ })
16
+ })
17
+
18
+ // Mock segment API to return error
19
+ await page.route('**/api/segment', (route) => {
20
+ route.fulfill({
21
+ status: 500,
22
+ contentType: 'application/json',
23
+ body: JSON.stringify({ detail: 'Segmentation failed' }),
24
+ })
25
+ })
26
+
27
+ const homePage = new HomePage(page)
28
+ await homePage.goto()
29
+ await homePage.waitForCasesToLoad()
30
+
31
+ await homePage.selectCase('sub-stroke0001')
32
+ await homePage.runSegmentation()
33
+
34
+ await homePage.expectErrorVisible()
35
+ await expect(homePage.errorAlert).toContainText(/failed/i)
36
+ })
37
+
38
+ test('shows error when cases fail to load', async ({ page }) => {
39
+ // Mock cases API to return error
40
+ await page.route('**/api/cases', (route) => {
41
+ route.fulfill({
42
+ status: 500,
43
+ contentType: 'application/json',
44
+ body: JSON.stringify({ detail: 'Server error' }),
45
+ })
46
+ })
47
+
48
+ const homePage = new HomePage(page)
49
+ await homePage.goto()
50
+
51
+ // Case selector should show error state
52
+ await expect(page.getByText(/failed to load/i)).toBeVisible()
53
+ })
54
+ })
e2e/fixtures.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test as base, expect } from '@playwright/test'
2
+
3
+ // API response mocks matching MSW handlers
4
+ const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
5
+ const MOCK_SEGMENT_RESPONSE = {
6
+ caseId: 'sub-stroke0001',
7
+ diceScore: 0.847,
8
+ volumeMl: 15.32,
9
+ elapsedSeconds: 12.5,
10
+ // Use real public NIfTI for visual testing (NiiVue demo image)
11
+ dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
12
+ predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
13
+ }
14
+
15
+ // Extend base test to include API mocking
16
+ export const test = base.extend({
17
+ // Auto-mock API routes for every test
18
+ page: async ({ page }, use) => {
19
+ // Mock GET /api/cases
20
+ await page.route('**/api/cases', (route) => {
21
+ route.fulfill({
22
+ status: 200,
23
+ contentType: 'application/json',
24
+ body: JSON.stringify({ cases: MOCK_CASES }),
25
+ })
26
+ })
27
+
28
+ // Mock POST /api/segment - return different caseId based on request
29
+ await page.route('**/api/segment', async (route) => {
30
+ const request = route.request()
31
+ const body = JSON.parse(request.postData() || '{}') as { case_id?: string }
32
+
33
+ // Simulate network delay
34
+ await new Promise((r) => setTimeout(r, 200))
35
+
36
+ route.fulfill({
37
+ status: 200,
38
+ contentType: 'application/json',
39
+ body: JSON.stringify({
40
+ ...MOCK_SEGMENT_RESPONSE,
41
+ caseId: body.case_id || 'sub-stroke0001',
42
+ }),
43
+ })
44
+ })
45
+
46
+ await use(page)
47
+ },
48
+ })
49
+
50
+ export { expect }
e2e/home.spec.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from './fixtures'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ test.describe('Home Page', () => {
5
+ test('displays main heading', async ({ page }) => {
6
+ const homePage = new HomePage(page)
7
+ await homePage.goto()
8
+
9
+ await expect(homePage.heading).toBeVisible()
10
+ })
11
+
12
+ test('loads case selector with options', async ({ page }) => {
13
+ const homePage = new HomePage(page)
14
+ await homePage.goto()
15
+ await homePage.waitForCasesToLoad()
16
+
17
+ // Verify selector has options
18
+ const options = await homePage.caseSelector.locator('option').count()
19
+ expect(options).toBeGreaterThan(1) // placeholder + cases
20
+ })
21
+
22
+ test('shows placeholder viewer initially', async ({ page }) => {
23
+ const homePage = new HomePage(page)
24
+ await homePage.goto()
25
+
26
+ await homePage.expectPlaceholderVisible()
27
+ })
28
+
29
+ test('run button disabled without case selected', async ({ page }) => {
30
+ const homePage = new HomePage(page)
31
+ await homePage.goto()
32
+ await homePage.waitForCasesToLoad()
33
+
34
+ await expect(homePage.runButton).toBeDisabled()
35
+ })
36
+ })
e2e/pages/HomePage.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Page, type Locator, expect } from '@playwright/test'
2
+
3
+ export class HomePage {
4
+ readonly page: Page
5
+ readonly heading: Locator
6
+ readonly caseSelector: Locator
7
+ readonly runButton: Locator
8
+ readonly processingText: Locator
9
+ readonly metricsPanel: Locator
10
+ readonly diceScore: Locator
11
+ readonly viewer: Locator
12
+ readonly placeholderText: Locator
13
+ readonly errorAlert: Locator
14
+
15
+ constructor(page: Page) {
16
+ this.page = page
17
+ this.heading = page.getByRole('heading', {
18
+ name: /stroke lesion segmentation/i,
19
+ })
20
+ this.caseSelector = page.getByRole('combobox')
21
+ this.runButton = page.getByRole('button', { name: /run segmentation/i })
22
+ this.processingText = page.getByText(/processing/i)
23
+ this.metricsPanel = page.getByRole('heading', { name: /results/i })
24
+ this.diceScore = page.getByText(/0\.\d{3}/)
25
+ this.viewer = page.locator('canvas')
26
+ this.placeholderText = page.getByText(/select a case and run segmentation/i)
27
+ this.errorAlert = page.getByRole('alert')
28
+ }
29
+
30
+ async goto() {
31
+ await this.page.goto('/')
32
+ await expect(this.heading).toBeVisible()
33
+ }
34
+
35
+ async waitForCasesToLoad() {
36
+ await expect(this.caseSelector).toBeEnabled({ timeout: 10000 })
37
+ }
38
+
39
+ async selectCase(caseId: string) {
40
+ await this.caseSelector.selectOption(caseId)
41
+ }
42
+
43
+ async runSegmentation() {
44
+ await this.runButton.click()
45
+ }
46
+
47
+ async waitForResults() {
48
+ await expect(this.metricsPanel).toBeVisible({ timeout: 30000 })
49
+ }
50
+
51
+ async expectViewerVisible() {
52
+ await expect(this.viewer).toBeVisible()
53
+ }
54
+
55
+ async expectPlaceholderVisible() {
56
+ await expect(this.placeholderText).toBeVisible()
57
+ }
58
+
59
+ async expectErrorVisible() {
60
+ await expect(this.errorAlert).toBeVisible()
61
+ }
62
+ }
e2e/segmentation-flow.spec.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from './fixtures'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ test.describe('Segmentation Flow', () => {
5
+ test('complete segmentation workflow', async ({ page }) => {
6
+ const homePage = new HomePage(page)
7
+ await homePage.goto()
8
+ await homePage.waitForCasesToLoad()
9
+
10
+ // Select a case
11
+ await homePage.selectCase('sub-stroke0001')
12
+ await expect(homePage.runButton).toBeEnabled()
13
+
14
+ // Run segmentation
15
+ await homePage.runSegmentation()
16
+
17
+ // Verify processing state
18
+ await expect(homePage.processingText).toBeVisible()
19
+
20
+ // Wait for results
21
+ await homePage.waitForResults()
22
+
23
+ // Verify results displayed
24
+ await expect(homePage.diceScore).toBeVisible()
25
+ await homePage.expectViewerVisible()
26
+
27
+ // Placeholder should be gone
28
+ await expect(homePage.placeholderText).not.toBeVisible()
29
+ })
30
+
31
+ test('can run multiple segmentations', async ({ page }) => {
32
+ const homePage = new HomePage(page)
33
+ await homePage.goto()
34
+ await homePage.waitForCasesToLoad()
35
+
36
+ // First run
37
+ await homePage.selectCase('sub-stroke0001')
38
+ await homePage.runSegmentation()
39
+ await homePage.waitForResults()
40
+
41
+ // Second run with different case
42
+ await homePage.selectCase('sub-stroke0002')
43
+ await homePage.runSegmentation()
44
+ await homePage.waitForResults()
45
+
46
+ // Results should still be visible
47
+ await expect(homePage.metricsPanel).toBeVisible()
48
+ })
49
+ })
eslint.config.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist', 'coverage']),
10
+ // Main source files - full React rules
11
+ {
12
+ files: ['src/**/*.{ts,tsx}'],
13
+ extends: [
14
+ js.configs.recommended,
15
+ tseslint.configs.recommended,
16
+ reactHooks.configs.flat.recommended,
17
+ reactRefresh.configs.vite,
18
+ ],
19
+ languageOptions: {
20
+ ecmaVersion: 2020,
21
+ globals: globals.browser,
22
+ },
23
+ },
24
+ // E2E tests - Playwright, not React (disable react-hooks rules)
25
+ {
26
+ files: ['e2e/**/*.{ts,tsx}'],
27
+ extends: [js.configs.recommended, tseslint.configs.recommended],
28
+ languageOptions: {
29
+ ecmaVersion: 2020,
30
+ globals: { ...globals.browser, ...globals.node },
31
+ },
32
+ },
33
+ ])
index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Stroke Lesion Segmentation</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "test": "vitest",
13
+ "test:ui": "vitest --ui",
14
+ "test:coverage": "vitest run --coverage",
15
+ "test:e2e": "playwright test",
16
+ "test:e2e:ui": "playwright test --ui",
17
+ "test:e2e:headed": "playwright test --headed",
18
+ "test:e2e:debug": "playwright test --debug"
19
+ },
20
+ "dependencies": {
21
+ "@niivue/niivue": "^0.65.0",
22
+ "react": "^19.2.0",
23
+ "react-dom": "^19.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@eslint/js": "^9.39.1",
27
+ "@playwright/test": "^1.57.0",
28
+ "@tailwindcss/vite": "^4.1.17",
29
+ "@testing-library/jest-dom": "^6.6.3",
30
+ "@testing-library/react": "^16.3.0",
31
+ "@testing-library/user-event": "^14.5.2",
32
+ "@types/node": "^24.10.1",
33
+ "@types/react": "^19.2.5",
34
+ "@types/react-dom": "^19.2.3",
35
+ "@vitejs/plugin-react": "^5.1.1",
36
+ "@vitest/coverage-v8": "^4.0.15",
37
+ "@vitest/ui": "^4.0.15",
38
+ "eslint": "^9.39.1",
39
+ "eslint-plugin-react-hooks": "^7.0.1",
40
+ "eslint-plugin-react-refresh": "^0.4.24",
41
+ "globals": "^16.5.0",
42
+ "jsdom": "^25.0.1",
43
+ "msw": "^2.7.0",
44
+ "tailwindcss": "^4.1.17",
45
+ "typescript": "~5.9.3",
46
+ "typescript-eslint": "^8.46.4",
47
+ "vite": "^7.2.4",
48
+ "vitest": "^4.0.15"
49
+ }
50
+ }
playwright.config.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: './e2e',
5
+ fullyParallel: true,
6
+ forbidOnly: !!process.env.CI,
7
+ retries: process.env.CI ? 2 : 0,
8
+ workers: process.env.CI ? 1 : undefined,
9
+ reporter: [
10
+ ['html', { open: 'never' }],
11
+ ['list'],
12
+ ...(process.env.CI ? [['github' as const]] : []),
13
+ ],
14
+ use: {
15
+ baseURL: 'http://localhost:5173',
16
+ trace: 'on-first-retry',
17
+ screenshot: 'only-on-failure',
18
+ },
19
+ projects: [
20
+ {
21
+ name: 'chromium',
22
+ use: { ...devices['Desktop Chrome'] },
23
+ },
24
+ ],
25
+ webServer: {
26
+ command: 'npm run dev',
27
+ url: 'http://localhost:5173',
28
+ reuseExistingServer: !process.env.CI,
29
+ timeout: 120000,
30
+ },
31
+ })
public/vite.svg ADDED
src/App.test.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { server } from './mocks/server'
5
+ import { errorHandlers } from './mocks/handlers'
6
+ import App from './App'
7
+
8
+ // Mock NiiVue to avoid WebGL in tests
9
+ vi.mock('@niivue/niivue', () => ({
10
+ Niivue: class MockNiivue {
11
+ attachToCanvas = vi.fn()
12
+ loadVolumes = vi.fn().mockResolvedValue(undefined)
13
+ cleanup = vi.fn()
14
+ gl = {
15
+ getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
16
+ }
17
+ opts = {}
18
+ },
19
+ }))
20
+
21
+ describe('App Integration', () => {
22
+ describe('Initial Render', () => {
23
+ it('renders main heading', () => {
24
+ render(<App />)
25
+
26
+ expect(
27
+ screen.getByRole('heading', { name: /stroke lesion segmentation/i })
28
+ ).toBeInTheDocument()
29
+ })
30
+
31
+ it('renders case selector', async () => {
32
+ render(<App />)
33
+
34
+ await waitFor(() => {
35
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
36
+ })
37
+ })
38
+
39
+ it('renders run button', () => {
40
+ render(<App />)
41
+
42
+ expect(
43
+ screen.getByRole('button', { name: /run segmentation/i })
44
+ ).toBeInTheDocument()
45
+ })
46
+
47
+ it('shows placeholder viewer message', () => {
48
+ render(<App />)
49
+
50
+ expect(
51
+ screen.getByText(/select a case and run segmentation/i)
52
+ ).toBeInTheDocument()
53
+ })
54
+ })
55
+
56
+ describe('Run Button State', () => {
57
+ it('disables run button when no case selected', async () => {
58
+ render(<App />)
59
+
60
+ await waitFor(() => {
61
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
62
+ })
63
+
64
+ expect(
65
+ screen.getByRole('button', { name: /run segmentation/i })
66
+ ).toBeDisabled()
67
+ })
68
+
69
+ it('enables run button when case selected', async () => {
70
+ const user = userEvent.setup()
71
+ render(<App />)
72
+
73
+ await waitFor(() => {
74
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
75
+ })
76
+
77
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
78
+
79
+ expect(
80
+ screen.getByRole('button', { name: /run segmentation/i })
81
+ ).toBeEnabled()
82
+ })
83
+ })
84
+
85
+ describe('Segmentation Flow', () => {
86
+ it('shows processing state when running', async () => {
87
+ const user = userEvent.setup()
88
+ render(<App />)
89
+
90
+ await waitFor(() => {
91
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
92
+ })
93
+
94
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
95
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
96
+
97
+ expect(screen.getByText(/processing/i)).toBeInTheDocument()
98
+ })
99
+
100
+ it('displays metrics after successful segmentation', async () => {
101
+ const user = userEvent.setup()
102
+ render(<App />)
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
106
+ })
107
+
108
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
109
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
110
+
111
+ await waitFor(() => {
112
+ expect(screen.getByText('0.847')).toBeInTheDocument()
113
+ })
114
+
115
+ expect(screen.getByText('15.32 mL')).toBeInTheDocument()
116
+ expect(screen.getByText(/12\.5s/)).toBeInTheDocument()
117
+ })
118
+
119
+ it('displays viewer after successful segmentation', async () => {
120
+ const user = userEvent.setup()
121
+ render(<App />)
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
125
+ })
126
+
127
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
128
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
129
+
130
+ await waitFor(() => {
131
+ expect(document.querySelector('canvas')).toBeInTheDocument()
132
+ })
133
+ })
134
+
135
+ it('hides placeholder after successful segmentation', async () => {
136
+ const user = userEvent.setup()
137
+ render(<App />)
138
+
139
+ await waitFor(() => {
140
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
141
+ })
142
+
143
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
144
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
145
+
146
+ await waitFor(() => {
147
+ expect(screen.getByText('0.847')).toBeInTheDocument()
148
+ })
149
+
150
+ expect(
151
+ screen.queryByText(/select a case and run segmentation/i)
152
+ ).not.toBeInTheDocument()
153
+ })
154
+ })
155
+
156
+ describe('Error Handling', () => {
157
+ it('shows error when segmentation fails', async () => {
158
+ server.use(errorHandlers.segmentServerError)
159
+ const user = userEvent.setup()
160
+
161
+ render(<App />)
162
+
163
+ await waitFor(() => {
164
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
165
+ })
166
+
167
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
168
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
169
+
170
+ await waitFor(() => {
171
+ expect(screen.getByRole('alert')).toBeInTheDocument()
172
+ })
173
+
174
+ expect(screen.getByText(/segmentation failed/i)).toBeInTheDocument()
175
+ })
176
+
177
+ it('allows retry after error', async () => {
178
+ server.use(errorHandlers.segmentServerError)
179
+ const user = userEvent.setup()
180
+
181
+ render(<App />)
182
+
183
+ await waitFor(() => {
184
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
185
+ })
186
+
187
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
188
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
189
+
190
+ await waitFor(() => {
191
+ expect(screen.getByRole('alert')).toBeInTheDocument()
192
+ })
193
+
194
+ // Reset to success handler
195
+ server.resetHandlers()
196
+
197
+ // Retry
198
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
199
+
200
+ await waitFor(() => {
201
+ expect(screen.getByText('0.847')).toBeInTheDocument()
202
+ })
203
+
204
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
205
+ })
206
+ })
207
+
208
+ describe('Multiple Runs', () => {
209
+ it('allows running segmentation on different cases', async () => {
210
+ const user = userEvent.setup()
211
+ render(<App />)
212
+
213
+ await waitFor(() => {
214
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
215
+ })
216
+
217
+ // First case
218
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
219
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
220
+
221
+ // Wait for first segmentation to complete
222
+ await waitFor(() => {
223
+ expect(screen.getByText('sub-stroke0001')).toBeInTheDocument()
224
+ })
225
+
226
+ // Wait for button to be ready again (not "Processing...")
227
+ await waitFor(() => {
228
+ expect(screen.getByRole('button', { name: /run segmentation/i })).toBeInTheDocument()
229
+ })
230
+
231
+ // Second case
232
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
233
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
234
+
235
+ await waitFor(() => {
236
+ expect(screen.getByText('sub-stroke0002')).toBeInTheDocument()
237
+ })
238
+ })
239
+ })
240
+ })
src/App.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { Layout } from './components/Layout'
3
+ import { CaseSelector } from './components/CaseSelector'
4
+ import { NiiVueViewer } from './components/NiiVueViewer'
5
+ import { MetricsPanel } from './components/MetricsPanel'
6
+ import { useSegmentation } from './hooks/useSegmentation'
7
+
8
+ export default function App() {
9
+ const [selectedCase, setSelectedCase] = useState<string | null>(null)
10
+ const { result, isLoading, error, runSegmentation } = useSegmentation()
11
+
12
+ const handleRunSegmentation = async () => {
13
+ if (selectedCase) {
14
+ await runSegmentation(selectedCase)
15
+ }
16
+ }
17
+
18
+ return (
19
+ <Layout>
20
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
21
+ {/* Left Panel: Controls */}
22
+ <div className="space-y-4">
23
+ <CaseSelector
24
+ selectedCase={selectedCase}
25
+ onSelectCase={setSelectedCase}
26
+ />
27
+
28
+ <button
29
+ onClick={handleRunSegmentation}
30
+ disabled={!selectedCase || isLoading}
31
+ className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600
32
+ disabled:cursor-not-allowed text-white font-medium
33
+ py-3 px-4 rounded-lg transition-colors"
34
+ >
35
+ {isLoading ? 'Processing...' : 'Run Segmentation'}
36
+ </button>
37
+
38
+ {error && (
39
+ <div role="alert" className="bg-red-900/50 text-red-300 p-3 rounded-lg">
40
+ {error}
41
+ </div>
42
+ )}
43
+
44
+ {result && <MetricsPanel metrics={result.metrics} />}
45
+ </div>
46
+
47
+ {/* Right Panel: Viewer */}
48
+ <div className="lg:col-span-2">
49
+ {result ? (
50
+ <NiiVueViewer
51
+ backgroundUrl={result.dwiUrl}
52
+ overlayUrl={result.predictionUrl}
53
+ />
54
+ ) : (
55
+ <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
56
+ <p className="text-gray-400">
57
+ Select a case and run segmentation to view results
58
+ </p>
59
+ </div>
60
+ )}
61
+ </div>
62
+ </div>
63
+ </Layout>
64
+ )
65
+ }
src/api/__tests__/client.test.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { server } from '../../mocks/server'
3
+ import { errorHandlers } from '../../mocks/handlers'
4
+ import { apiClient } from '../client'
5
+
6
+ describe('apiClient', () => {
7
+ describe('getCases', () => {
8
+ it('returns list of case IDs', async () => {
9
+ const result = await apiClient.getCases()
10
+
11
+ expect(result.cases).toHaveLength(3)
12
+ expect(result.cases).toContain('sub-stroke0001')
13
+ })
14
+
15
+ it('throws ApiError on server error', async () => {
16
+ server.use(errorHandlers.casesServerError)
17
+
18
+ await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i)
19
+ })
20
+
21
+ it('throws ApiError on network error', async () => {
22
+ server.use(errorHandlers.casesNetworkError)
23
+
24
+ await expect(apiClient.getCases()).rejects.toThrow()
25
+ })
26
+ })
27
+
28
+ describe('runSegmentation', () => {
29
+ it('returns segmentation result', async () => {
30
+ const result = await apiClient.runSegmentation('sub-stroke0001')
31
+
32
+ expect(result.caseId).toBe('sub-stroke0001')
33
+ expect(result.diceScore).toBe(0.847)
34
+ expect(result.volumeMl).toBe(15.32)
35
+ expect(result.dwiUrl).toContain('dwi.nii.gz')
36
+ expect(result.predictionUrl).toContain('prediction.nii.gz')
37
+ })
38
+
39
+ it('sends fast_mode=false parameter (slower processing)', async () => {
40
+ const result = await apiClient.runSegmentation('sub-stroke0001', false)
41
+
42
+ // Mock returns 45.0s when fast_mode=false
43
+ expect(result.elapsedSeconds).toBe(45.0)
44
+ })
45
+
46
+ it('defaults fast_mode to true (faster processing)', async () => {
47
+ const result = await apiClient.runSegmentation('sub-stroke0001')
48
+
49
+ // Mock returns 12.5s when fast_mode=true (the default)
50
+ expect(result.elapsedSeconds).toBe(12.5)
51
+ })
52
+
53
+ it('throws ApiError on server error', async () => {
54
+ server.use(errorHandlers.segmentServerError)
55
+
56
+ await expect(
57
+ apiClient.runSegmentation('sub-stroke0001')
58
+ ).rejects.toThrow(/segmentation failed/i)
59
+ })
60
+ })
61
+ })
src/api/client.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CasesResponse, SegmentResponse } from '../types'
2
+
3
+ function getApiBase(): string {
4
+ const url = import.meta.env.VITE_API_URL
5
+
6
+ // In production, VITE_API_URL must be set - fail fast with clear error
7
+ if (import.meta.env.PROD && !url) {
8
+ throw new Error(
9
+ 'VITE_API_URL environment variable is required in production. ' +
10
+ 'Set it to the backend API URL (e.g., https://your-app.hf.space).'
11
+ )
12
+ }
13
+
14
+ // In development, fall back to localhost
15
+ return url || 'http://localhost:7860'
16
+ }
17
+
18
+ const API_BASE = getApiBase()
19
+
20
+ export class ApiError extends Error {
21
+ status: number
22
+ detail?: string
23
+
24
+ constructor(message: string, status: number, detail?: string) {
25
+ super(message)
26
+ this.name = 'ApiError'
27
+ this.status = status
28
+ this.detail = detail
29
+ }
30
+ }
31
+
32
+ class ApiClient {
33
+ private baseUrl: string
34
+
35
+ constructor(baseUrl: string) {
36
+ this.baseUrl = baseUrl
37
+ }
38
+
39
+ async getCases(signal?: AbortSignal): Promise<CasesResponse> {
40
+ const response = await fetch(`${this.baseUrl}/api/cases`, { signal })
41
+
42
+ if (!response.ok) {
43
+ const error = await response.json().catch(() => ({}))
44
+ throw new ApiError(
45
+ `Failed to fetch cases: ${response.statusText}`,
46
+ response.status,
47
+ error.detail
48
+ )
49
+ }
50
+
51
+ return response.json()
52
+ }
53
+
54
+ async runSegmentation(
55
+ caseId: string,
56
+ fastMode: boolean = true,
57
+ signal?: AbortSignal
58
+ ): Promise<SegmentResponse> {
59
+ const response = await fetch(`${this.baseUrl}/api/segment`, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify({
65
+ case_id: caseId,
66
+ fast_mode: fastMode,
67
+ }),
68
+ signal,
69
+ })
70
+
71
+ if (!response.ok) {
72
+ const error = await response.json().catch(() => ({}))
73
+ throw new ApiError(
74
+ `Segmentation failed: ${error.detail || response.statusText}`,
75
+ response.status,
76
+ error.detail
77
+ )
78
+ }
79
+
80
+ return response.json()
81
+ }
82
+ }
83
+
84
+ export const apiClient = new ApiClient(API_BASE)
src/api/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { apiClient, ApiError } from './client'
src/assets/react.svg ADDED
src/components/CaseSelector.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { apiClient } from '../api/client'
3
+
4
+ interface CaseSelectorProps {
5
+ selectedCase: string | null
6
+ onSelectCase: (caseId: string) => void
7
+ }
8
+
9
+ export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) {
10
+ const [cases, setCases] = useState<string[]>([])
11
+ const [isLoading, setIsLoading] = useState(true)
12
+ const [error, setError] = useState<string | null>(null)
13
+
14
+ useEffect(() => {
15
+ const abortController = new AbortController()
16
+
17
+ const fetchCases = async () => {
18
+ try {
19
+ const data = await apiClient.getCases(abortController.signal)
20
+ setCases(data.cases)
21
+ } catch (err) {
22
+ // Ignore abort errors - component unmounted
23
+ if (err instanceof Error && err.name === 'AbortError') return
24
+
25
+ const message = err instanceof Error ? err.message : 'Unknown error'
26
+ setError(`Failed to load cases: ${message}`)
27
+ } finally {
28
+ if (!abortController.signal.aborted) {
29
+ setIsLoading(false)
30
+ }
31
+ }
32
+ }
33
+
34
+ fetchCases()
35
+
36
+ return () => abortController.abort()
37
+ }, [])
38
+
39
+ if (isLoading) {
40
+ return (
41
+ <div className="bg-gray-800 rounded-lg p-4">
42
+ <p className="text-gray-400">Loading cases...</p>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ if (error) {
48
+ return (
49
+ <div className="bg-red-900/50 rounded-lg p-4">
50
+ <p className="text-red-300">{error}</p>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ return (
56
+ <div className="bg-gray-800 rounded-lg p-4">
57
+ <label className="block text-sm font-medium mb-2">Select Case</label>
58
+ <select
59
+ value={selectedCase || ''}
60
+ onChange={(e) => onSelectCase(e.target.value)}
61
+ className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
62
+ text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
63
+ >
64
+ <option value="">Choose a case...</option>
65
+ {cases.map((caseId) => (
66
+ <option key={caseId} value={caseId}>
67
+ {caseId}
68
+ </option>
69
+ ))}
70
+ </select>
71
+ </div>
72
+ )
73
+ }
src/components/Layout.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface LayoutProps {
4
+ children: ReactNode
5
+ }
6
+
7
+ export function Layout({ children }: LayoutProps) {
8
+ return (
9
+ <div className="min-h-screen bg-gray-950 text-white">
10
+ <header className="border-b border-gray-800 py-4">
11
+ <div className="container mx-auto px-4">
12
+ <h1 className="text-2xl font-bold">Stroke Lesion Segmentation</h1>
13
+ <p className="text-gray-400 text-sm mt-1">
14
+ DeepISLES segmentation on ISLES24 dataset
15
+ </p>
16
+ </div>
17
+ </header>
18
+ <main className="container mx-auto px-4 py-6">{children}</main>
19
+ </div>
20
+ )
21
+ }
src/components/MetricsPanel.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metrics } from '../types'
2
+
3
+ interface MetricsPanelProps {
4
+ metrics: Metrics
5
+ }
6
+
7
+ export function MetricsPanel({ metrics }: MetricsPanelProps) {
8
+ return (
9
+ <div className="bg-gray-800 rounded-lg p-4 space-y-3">
10
+ <h3 className="font-medium text-lg">Results</h3>
11
+
12
+ <div className="grid grid-cols-2 gap-3 text-sm">
13
+ <div>
14
+ <span className="text-gray-400">Case:</span>
15
+ <span className="ml-2 font-mono">{metrics.caseId}</span>
16
+ </div>
17
+
18
+ {metrics.diceScore !== null && (
19
+ <div>
20
+ <span className="text-gray-400">Dice Score:</span>
21
+ <span className="ml-2 font-mono text-green-400">
22
+ {metrics.diceScore.toFixed(3)}
23
+ </span>
24
+ </div>
25
+ )}
26
+
27
+ {metrics.volumeMl !== null && (
28
+ <div>
29
+ <span className="text-gray-400">Volume:</span>
30
+ <span className="ml-2 font-mono">
31
+ {metrics.volumeMl.toFixed(2)} mL
32
+ </span>
33
+ </div>
34
+ )}
35
+
36
+ <div>
37
+ <span className="text-gray-400">Time:</span>
38
+ <span className="ml-2 font-mono">
39
+ {metrics.elapsedSeconds.toFixed(1)}s
40
+ </span>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ )
45
+ }
src/components/NiiVueViewer.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState } from 'react'
2
+ import { Niivue } from '@niivue/niivue'
3
+
4
+ interface NiiVueViewerProps {
5
+ backgroundUrl: string
6
+ overlayUrl?: string
7
+ onError?: (error: string) => void
8
+ }
9
+
10
+ export function NiiVueViewer({ backgroundUrl, overlayUrl, onError }: NiiVueViewerProps) {
11
+ const canvasRef = useRef<HTMLCanvasElement>(null)
12
+ const nvRef = useRef<Niivue | null>(null)
13
+ const onErrorRef = useRef(onError)
14
+ const [loadError, setLoadError] = useState<string | null>(null)
15
+
16
+ // Keep onError ref current without triggering effect re-runs
17
+ useEffect(() => {
18
+ onErrorRef.current = onError
19
+ })
20
+
21
+ // Effect 1: Mount/unmount - instantiate and cleanup NiiVue ONCE
22
+ useEffect(() => {
23
+ if (!canvasRef.current) return
24
+
25
+ const nv = new Niivue({
26
+ backColor: [0.05, 0.05, 0.05, 1],
27
+ show3Dcrosshair: true,
28
+ crosshairColor: [1, 0, 0, 0.5],
29
+ })
30
+ nv.attachToCanvas(canvasRef.current)
31
+ nvRef.current = nv
32
+
33
+ // Cleanup on unmount ONLY - CRITICAL: Release WebGL context
34
+ // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
35
+ // navigating between cases will exhaust contexts and break the viewer.
36
+ return () => {
37
+ // Capture gl BEFORE cleanup (cleanup may null internal state)
38
+ const gl = nv.gl
39
+ try {
40
+ // NiiVue's cleanup() releases event listeners and observers
41
+ // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
42
+ nv.cleanup()
43
+ // Force WebGL context loss to free GPU memory immediately
44
+ if (gl) {
45
+ const ext = gl.getExtension('WEBGL_lose_context')
46
+ ext?.loseContext()
47
+ }
48
+ } catch {
49
+ // Ignore cleanup errors
50
+ }
51
+ nvRef.current = null
52
+ }
53
+ }, [])
54
+
55
+ // Effect 2: URL changes - reload volumes on existing NiiVue instance
56
+ // Uses isCurrent flag to ignore stale loads when URLs change rapidly
57
+ useEffect(() => {
58
+ const nv = nvRef.current
59
+ if (!nv) return
60
+
61
+ let isCurrent = true
62
+
63
+ // Clear previous error before new load (valid pattern for async operations)
64
+ // eslint-disable-next-line react-hooks/set-state-in-effect
65
+ setLoadError(null)
66
+
67
+ const volumes: Array<{ url: string; colormap: string; opacity: number }> = [
68
+ { url: backgroundUrl, colormap: 'gray', opacity: 1 },
69
+ ]
70
+
71
+ if (overlayUrl) {
72
+ volumes.push({
73
+ url: overlayUrl,
74
+ colormap: 'red',
75
+ opacity: 0.5,
76
+ })
77
+ }
78
+
79
+ // Load volumes with error handling - ignore stale results
80
+ nv.loadVolumes(volumes).catch((err: unknown) => {
81
+ if (!isCurrent) return // Ignore errors from stale loads
82
+ const message = err instanceof Error ? err.message : 'Failed to load volume'
83
+ setLoadError(message)
84
+ onErrorRef.current?.(message)
85
+ })
86
+
87
+ // Cleanup: mark this effect instance as stale
88
+ return () => {
89
+ isCurrent = false
90
+ }
91
+ }, [backgroundUrl, overlayUrl])
92
+
93
+ return (
94
+ <div className="bg-gray-900 rounded-lg p-2">
95
+ <canvas ref={canvasRef} className="w-full h-[500px] rounded" />
96
+ {loadError && (
97
+ <div className="mt-2 p-2 bg-red-900/50 rounded text-red-300 text-sm">
98
+ Failed to load volume: {loadError}
99
+ </div>
100
+ )}
101
+ <div className="flex gap-4 mt-2 text-xs text-gray-400">
102
+ <span>Scroll: Navigate slices</span>
103
+ <span>Drag: Adjust contrast</span>
104
+ <span>Right-click: Pan</span>
105
+ </div>
106
+ </div>
107
+ )
108
+ }
src/components/__tests__/CaseSelector.test.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { server } from '../../mocks/server'
5
+ import { errorHandlers } from '../../mocks/handlers'
6
+ import { CaseSelector } from '../CaseSelector'
7
+
8
+ describe('CaseSelector', () => {
9
+ const mockOnSelectCase = vi.fn()
10
+
11
+ beforeEach(() => {
12
+ mockOnSelectCase.mockClear()
13
+ })
14
+
15
+ it('shows loading state initially', () => {
16
+ render(
17
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
18
+ )
19
+
20
+ expect(screen.getByText(/loading/i)).toBeInTheDocument()
21
+ })
22
+
23
+ it('renders select after loading', async () => {
24
+ render(
25
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
26
+ )
27
+
28
+ await waitFor(() => {
29
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
30
+ })
31
+ })
32
+
33
+ it('displays all cases as options', async () => {
34
+ render(
35
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
36
+ )
37
+
38
+ await waitFor(() => {
39
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
40
+ })
41
+
42
+ expect(screen.getByRole('option', { name: /sub-stroke0001/i })).toBeInTheDocument()
43
+ expect(screen.getByRole('option', { name: /sub-stroke0002/i })).toBeInTheDocument()
44
+ expect(screen.getByRole('option', { name: /sub-stroke0003/i })).toBeInTheDocument()
45
+ })
46
+
47
+ it('has placeholder option', async () => {
48
+ render(
49
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
50
+ )
51
+
52
+ await waitFor(() => {
53
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
54
+ })
55
+
56
+ expect(screen.getByRole('option', { name: /choose a case/i })).toBeInTheDocument()
57
+ })
58
+
59
+ it('calls onSelectCase when case selected', async () => {
60
+ const user = userEvent.setup()
61
+
62
+ render(
63
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
64
+ )
65
+
66
+ await waitFor(() => {
67
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
68
+ })
69
+
70
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
71
+
72
+ expect(mockOnSelectCase).toHaveBeenCalledWith('sub-stroke0001')
73
+ })
74
+
75
+ it('shows selected case value', async () => {
76
+ render(
77
+ <CaseSelector
78
+ selectedCase="sub-stroke0002"
79
+ onSelectCase={mockOnSelectCase}
80
+ />
81
+ )
82
+
83
+ await waitFor(() => {
84
+ expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002')
85
+ })
86
+ })
87
+
88
+ it('shows error state on API failure', async () => {
89
+ server.use(errorHandlers.casesServerError)
90
+
91
+ render(
92
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
93
+ )
94
+
95
+ await waitFor(() => {
96
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
97
+ })
98
+ })
99
+
100
+ it('applies correct styling', async () => {
101
+ render(
102
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
103
+ )
104
+
105
+ await waitFor(() => {
106
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
107
+ })
108
+
109
+ const container = screen.getByRole('combobox').closest('div')
110
+ expect(container).toHaveClass('bg-gray-800')
111
+ })
112
+ })
src/components/__tests__/Layout.test.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { Layout } from '../Layout'
4
+
5
+ describe('Layout', () => {
6
+ it('renders header with title', () => {
7
+ render(<Layout>Content</Layout>)
8
+
9
+ expect(
10
+ screen.getByRole('heading', { name: /stroke lesion segmentation/i })
11
+ ).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders subtitle', () => {
15
+ render(<Layout>Content</Layout>)
16
+
17
+ expect(screen.getByText(/deepisles segmentation/i)).toBeInTheDocument()
18
+ })
19
+
20
+ it('renders children in main area', () => {
21
+ render(
22
+ <Layout>
23
+ <div data-testid="child">Test Child</div>
24
+ </Layout>
25
+ )
26
+
27
+ expect(screen.getByTestId('child')).toBeInTheDocument()
28
+ })
29
+
30
+ it('has accessible landmark structure', () => {
31
+ render(<Layout>Content</Layout>)
32
+
33
+ expect(screen.getByRole('banner')).toBeInTheDocument()
34
+ expect(screen.getByRole('main')).toBeInTheDocument()
35
+ })
36
+
37
+ it('applies dark theme styling', () => {
38
+ render(<Layout>Content</Layout>)
39
+
40
+ const container = screen.getByRole('banner').parentElement
41
+ expect(container).toHaveClass('bg-gray-950')
42
+ })
43
+ })
src/components/__tests__/MetricsPanel.test.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { MetricsPanel } from '../MetricsPanel'
4
+
5
+ describe('MetricsPanel', () => {
6
+ const defaultMetrics = {
7
+ caseId: 'sub-stroke0001',
8
+ diceScore: 0.847,
9
+ volumeMl: 15.32,
10
+ elapsedSeconds: 12.5,
11
+ }
12
+
13
+ it('renders results heading', () => {
14
+ render(<MetricsPanel metrics={defaultMetrics} />)
15
+
16
+ expect(
17
+ screen.getByRole('heading', { name: /results/i })
18
+ ).toBeInTheDocument()
19
+ })
20
+
21
+ it('displays case ID', () => {
22
+ render(<MetricsPanel metrics={defaultMetrics} />)
23
+
24
+ expect(screen.getByText('sub-stroke0001')).toBeInTheDocument()
25
+ })
26
+
27
+ it('displays dice score with 3 decimal places', () => {
28
+ render(<MetricsPanel metrics={defaultMetrics} />)
29
+
30
+ expect(screen.getByText('0.847')).toBeInTheDocument()
31
+ })
32
+
33
+ it('displays volume in mL with 2 decimal places', () => {
34
+ render(<MetricsPanel metrics={defaultMetrics} />)
35
+
36
+ expect(screen.getByText('15.32 mL')).toBeInTheDocument()
37
+ })
38
+
39
+ it('displays elapsed time with 1 decimal place', () => {
40
+ render(<MetricsPanel metrics={defaultMetrics} />)
41
+
42
+ expect(screen.getByText('12.5s')).toBeInTheDocument()
43
+ })
44
+
45
+ it('hides dice score row when null', () => {
46
+ render(
47
+ <MetricsPanel metrics={{ ...defaultMetrics, diceScore: null }} />
48
+ )
49
+
50
+ expect(screen.queryByText(/dice score/i)).not.toBeInTheDocument()
51
+ })
52
+
53
+ it('hides volume row when null', () => {
54
+ render(
55
+ <MetricsPanel metrics={{ ...defaultMetrics, volumeMl: null }} />
56
+ )
57
+
58
+ expect(screen.queryByText(/volume/i)).not.toBeInTheDocument()
59
+ })
60
+
61
+ it('applies card styling', () => {
62
+ render(<MetricsPanel metrics={defaultMetrics} />)
63
+
64
+ const panel = screen.getByRole('heading', { name: /results/i }).parentElement
65
+ expect(panel).toHaveClass('bg-gray-800', 'rounded-lg')
66
+ })
67
+ })
src/components/__tests__/NiiVueViewer.test.tsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { NiiVueViewer } from '../NiiVueViewer'
4
+
5
+ // Store mock function references so tests can verify calls
6
+ const mockLoadVolumes = vi.fn().mockResolvedValue(undefined)
7
+ const mockCleanup = vi.fn()
8
+ const mockAttachToCanvas = vi.fn()
9
+ const mockLoseContext = vi.fn()
10
+
11
+ // Mock the NiiVue module since it requires actual WebGL
12
+ vi.mock('@niivue/niivue', () => ({
13
+ Niivue: class MockNiivue {
14
+ attachToCanvas = mockAttachToCanvas
15
+ loadVolumes = mockLoadVolumes
16
+ setSliceType = vi.fn()
17
+ cleanup = mockCleanup
18
+ gl = {
19
+ getExtension: vi.fn(() => ({ loseContext: mockLoseContext })),
20
+ }
21
+ opts = {}
22
+ },
23
+ }))
24
+
25
+ describe('NiiVueViewer', () => {
26
+ const defaultProps = {
27
+ backgroundUrl: 'http://localhost:7860/files/dwi.nii.gz',
28
+ }
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks()
32
+ })
33
+
34
+ it('renders canvas element', () => {
35
+ render(<NiiVueViewer {...defaultProps} />)
36
+
37
+ expect(document.querySelector('canvas')).toBeInTheDocument()
38
+ })
39
+
40
+ it('renders container with correct styling', () => {
41
+ render(<NiiVueViewer {...defaultProps} />)
42
+
43
+ const container = document.querySelector('canvas')?.parentElement
44
+ expect(container).toHaveClass('bg-gray-900')
45
+ })
46
+
47
+ it('renders help text for controls', () => {
48
+ render(<NiiVueViewer {...defaultProps} />)
49
+
50
+ expect(screen.getByText(/scroll/i)).toBeInTheDocument()
51
+ expect(screen.getByText(/drag/i)).toBeInTheDocument()
52
+ })
53
+
54
+ it('attaches NiiVue to canvas on mount', () => {
55
+ render(<NiiVueViewer {...defaultProps} />)
56
+
57
+ expect(mockAttachToCanvas).toHaveBeenCalled()
58
+ // Verify it was called with a canvas element
59
+ const arg = mockAttachToCanvas.mock.calls[0][0]
60
+ expect(arg).toBeInstanceOf(HTMLCanvasElement)
61
+ })
62
+
63
+ it('loads background volume on mount', () => {
64
+ render(<NiiVueViewer {...defaultProps} />)
65
+
66
+ expect(mockLoadVolumes).toHaveBeenCalledWith([
67
+ { url: defaultProps.backgroundUrl, colormap: 'gray', opacity: 1 },
68
+ ])
69
+ })
70
+
71
+ it('loads both background and overlay when overlayUrl provided', () => {
72
+ const overlayUrl = 'http://localhost:7860/files/prediction.nii.gz'
73
+
74
+ render(
75
+ <NiiVueViewer
76
+ {...defaultProps}
77
+ overlayUrl={overlayUrl}
78
+ />
79
+ )
80
+
81
+ expect(mockLoadVolumes).toHaveBeenCalledWith([
82
+ { url: defaultProps.backgroundUrl, colormap: 'gray', opacity: 1 },
83
+ { url: overlayUrl, colormap: 'red', opacity: 0.5 },
84
+ ])
85
+ })
86
+
87
+ it('calls cleanup on unmount', () => {
88
+ const { unmount } = render(<NiiVueViewer {...defaultProps} />)
89
+
90
+ unmount()
91
+
92
+ expect(mockCleanup).toHaveBeenCalled()
93
+ expect(mockLoseContext).toHaveBeenCalled()
94
+ })
95
+
96
+ it('sets canvas dimensions', () => {
97
+ render(<NiiVueViewer {...defaultProps} />)
98
+
99
+ const canvas = document.querySelector('canvas')
100
+ expect(canvas).toHaveClass('w-full', 'h-[500px]')
101
+ })
102
+
103
+ it('displays error when volume loading fails', async () => {
104
+ const errorMessage = 'Network error loading volume'
105
+ mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage))
106
+
107
+ render(<NiiVueViewer {...defaultProps} />)
108
+
109
+ // Wait for error to be displayed
110
+ const errorElement = await screen.findByText(/failed to load volume/i)
111
+ expect(errorElement).toBeInTheDocument()
112
+ expect(errorElement).toHaveTextContent(errorMessage)
113
+ })
114
+
115
+ it('calls onError callback when volume loading fails', async () => {
116
+ const errorMessage = 'Network error'
117
+ const onError = vi.fn()
118
+ mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage))
119
+
120
+ render(<NiiVueViewer {...defaultProps} onError={onError} />)
121
+
122
+ // Wait for error callback to be invoked
123
+ await vi.waitFor(() => {
124
+ expect(onError).toHaveBeenCalledWith(errorMessage)
125
+ })
126
+ })
127
+
128
+ it('ignores errors from stale loads after URL change', async () => {
129
+ const onError = vi.fn()
130
+ // First load succeeds, second load fails slowly
131
+ let rejectSecondLoad: (error: Error) => void
132
+ mockLoadVolumes
133
+ .mockResolvedValueOnce(undefined)
134
+ .mockImplementationOnce(() => new Promise((_, reject) => {
135
+ rejectSecondLoad = reject
136
+ }))
137
+
138
+ const { rerender } = render(
139
+ <NiiVueViewer backgroundUrl="http://localhost/first.nii.gz" onError={onError} />
140
+ )
141
+
142
+ // Change URL - starts second load
143
+ rerender(
144
+ <NiiVueViewer backgroundUrl="http://localhost/second.nii.gz" onError={onError} />
145
+ )
146
+
147
+ // Change URL again - makes second load stale
148
+ rerender(
149
+ <NiiVueViewer backgroundUrl="http://localhost/third.nii.gz" onError={onError} />
150
+ )
151
+
152
+ // Now reject the second load (stale)
153
+ rejectSecondLoad!(new Error('Stale load error'))
154
+
155
+ // Wait a tick and verify onError was NOT called with stale error
156
+ await vi.waitFor(() => {
157
+ expect(onError).not.toHaveBeenCalledWith('Stale load error')
158
+ })
159
+ })
160
+ })
src/components/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export { Layout } from './Layout'
2
+ export { MetricsPanel } from './MetricsPanel'
3
+ export { CaseSelector } from './CaseSelector'
4
+ export { NiiVueViewer } from './NiiVueViewer'
src/hooks/__tests__/useSegmentation.test.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderHook, waitFor, act } from '@testing-library/react'
3
+ import { server } from '../../mocks/server'
4
+ import { errorHandlers } from '../../mocks/handlers'
5
+ import { useSegmentation } from '../useSegmentation'
6
+
7
+ describe('useSegmentation', () => {
8
+ it('starts with null result and not loading', () => {
9
+ const { result } = renderHook(() => useSegmentation())
10
+
11
+ expect(result.current.result).toBeNull()
12
+ expect(result.current.isLoading).toBe(false)
13
+ expect(result.current.error).toBeNull()
14
+ })
15
+
16
+ it('sets loading state during segmentation', async () => {
17
+ const { result } = renderHook(() => useSegmentation())
18
+
19
+ act(() => {
20
+ result.current.runSegmentation('sub-stroke0001')
21
+ })
22
+
23
+ expect(result.current.isLoading).toBe(true)
24
+
25
+ await waitFor(() => {
26
+ expect(result.current.isLoading).toBe(false)
27
+ })
28
+ })
29
+
30
+ it('returns result on success', async () => {
31
+ const { result } = renderHook(() => useSegmentation())
32
+
33
+ await act(async () => {
34
+ await result.current.runSegmentation('sub-stroke0001')
35
+ })
36
+
37
+ expect(result.current.result).not.toBeNull()
38
+ expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
39
+ expect(result.current.result?.metrics.diceScore).toBe(0.847)
40
+ expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
41
+ })
42
+
43
+ it('sets error on failure', async () => {
44
+ server.use(errorHandlers.segmentServerError)
45
+
46
+ const { result } = renderHook(() => useSegmentation())
47
+
48
+ await act(async () => {
49
+ await result.current.runSegmentation('sub-stroke0001')
50
+ })
51
+
52
+ expect(result.current.error).toMatch(/segmentation failed/i)
53
+ expect(result.current.result).toBeNull()
54
+ })
55
+
56
+ it('clears previous error on new request', async () => {
57
+ server.use(errorHandlers.segmentServerError)
58
+ const { result } = renderHook(() => useSegmentation())
59
+
60
+ // First request fails
61
+ await act(async () => {
62
+ await result.current.runSegmentation('sub-stroke0001')
63
+ })
64
+ expect(result.current.error).not.toBeNull()
65
+
66
+ // Reset to success handler
67
+ server.resetHandlers()
68
+
69
+ // Second request succeeds
70
+ await act(async () => {
71
+ await result.current.runSegmentation('sub-stroke0001')
72
+ })
73
+
74
+ expect(result.current.error).toBeNull()
75
+ expect(result.current.result).not.toBeNull()
76
+ })
77
+
78
+ it('clears previous result on new request', async () => {
79
+ const { result } = renderHook(() => useSegmentation())
80
+
81
+ // First request
82
+ await act(async () => {
83
+ await result.current.runSegmentation('sub-stroke0001')
84
+ })
85
+ expect(result.current.result).not.toBeNull()
86
+
87
+ // Start second request - result should clear while loading
88
+ act(() => {
89
+ result.current.runSegmentation('sub-stroke0002')
90
+ })
91
+
92
+ // While loading, previous result is still available
93
+ // (or you could clear it - depends on UX preference)
94
+ expect(result.current.isLoading).toBe(true)
95
+ })
96
+ })
src/hooks/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { useSegmentation } from './useSegmentation'
src/hooks/useSegmentation.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef } from 'react'
2
+ import { apiClient } from '../api/client'
3
+ import type { SegmentationResult } from '../types'
4
+
5
+ export function useSegmentation() {
6
+ const [result, setResult] = useState<SegmentationResult | null>(null)
7
+ const [isLoading, setIsLoading] = useState(false)
8
+ const [error, setError] = useState<string | null>(null)
9
+
10
+ // Track the current request to prevent race conditions
11
+ // Each new request gets a unique token; only the latest request's results are applied
12
+ const currentRequestRef = useRef<number>(0)
13
+ const abortControllerRef = useRef<AbortController | null>(null)
14
+
15
+ const runSegmentation = useCallback(async (caseId: string, fastMode = true) => {
16
+ // Cancel any in-flight request
17
+ abortControllerRef.current?.abort()
18
+ const abortController = new AbortController()
19
+ abortControllerRef.current = abortController
20
+
21
+ // Increment request token to track this request
22
+ const requestToken = ++currentRequestRef.current
23
+
24
+ setIsLoading(true)
25
+ setError(null)
26
+
27
+ try {
28
+ const data = await apiClient.runSegmentation(caseId, fastMode, abortController.signal)
29
+
30
+ // Only apply results if this is still the current request
31
+ // Prevents stale responses from overwriting newer results
32
+ if (requestToken !== currentRequestRef.current) return
33
+
34
+ setResult({
35
+ dwiUrl: data.dwiUrl,
36
+ predictionUrl: data.predictionUrl,
37
+ metrics: {
38
+ caseId: data.caseId,
39
+ diceScore: data.diceScore,
40
+ volumeMl: data.volumeMl,
41
+ elapsedSeconds: data.elapsedSeconds,
42
+ },
43
+ })
44
+ } catch (err) {
45
+ // Ignore abort errors - user intentionally cancelled
46
+ if (err instanceof Error && err.name === 'AbortError') return
47
+
48
+ // Only apply error if this is still the current request
49
+ if (requestToken !== currentRequestRef.current) return
50
+
51
+ const message = err instanceof Error ? err.message : 'Unknown error'
52
+ setError(message)
53
+ setResult(null)
54
+ } finally {
55
+ // Only clear loading if this is still the current request
56
+ if (requestToken === currentRequestRef.current) {
57
+ setIsLoading(false)
58
+ }
59
+ }
60
+ }, [])
61
+
62
+ return { result, isLoading, error, runSegmentation }
63
+ }
src/index.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @import "tailwindcss";
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
src/mocks/handlers.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse, delay } from 'msw'
2
+
3
+ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
4
+
5
+ export const handlers = [
6
+ http.get(`${API_BASE}/api/cases`, async () => {
7
+ await delay(100)
8
+ return HttpResponse.json({
9
+ cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'],
10
+ })
11
+ }),
12
+
13
+ http.post(`${API_BASE}/api/segment`, async ({ request }) => {
14
+ const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
15
+ await delay(200)
16
+ return HttpResponse.json({
17
+ caseId: body.case_id,
18
+ diceScore: 0.847,
19
+ volumeMl: 15.32,
20
+ // Reflect fast_mode in response - slower when fast_mode=false
21
+ elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5,
22
+ dwiUrl: `${API_BASE}/files/dwi.nii.gz`,
23
+ predictionUrl: `${API_BASE}/files/prediction.nii.gz`,
24
+ })
25
+ }),
26
+ ]
27
+
28
+ // Error handlers for testing error states
29
+ export const errorHandlers = {
30
+ casesServerError: http.get(`${API_BASE}/api/cases`, () => {
31
+ return HttpResponse.json(
32
+ { detail: 'Internal server error' },
33
+ { status: 500 }
34
+ )
35
+ }),
36
+
37
+ casesNetworkError: http.get(`${API_BASE}/api/cases`, () => {
38
+ return HttpResponse.error()
39
+ }),
40
+
41
+ segmentServerError: http.post(`${API_BASE}/api/segment`, () => {
42
+ return HttpResponse.json(
43
+ { detail: 'Segmentation failed: out of memory' },
44
+ { status: 500 }
45
+ )
46
+ }),
47
+
48
+ segmentTimeout: http.post(`${API_BASE}/api/segment`, async () => {
49
+ await delay(30000)
50
+ return HttpResponse.json({ detail: 'Timeout' }, { status: 504 })
51
+ }),
52
+ }
src/mocks/server.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { setupServer } from 'msw/node'
2
+ import { handlers } from './handlers'
3
+
4
+ export const server = setupServer(...handlers)
src/test/fixtures.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SegmentationResult, CasesResponse } from '../types'
2
+
3
+ export const mockCases: string[] = [
4
+ 'sub-stroke0001',
5
+ 'sub-stroke0002',
6
+ 'sub-stroke0003',
7
+ ]
8
+
9
+ export const mockCasesResponse: CasesResponse = {
10
+ cases: mockCases,
11
+ }
12
+
13
+ export const mockSegmentationResult: SegmentationResult = {
14
+ dwiUrl: 'http://localhost:7860/files/dwi.nii.gz',
15
+ predictionUrl: 'http://localhost:7860/files/prediction.nii.gz',
16
+ metrics: {
17
+ caseId: 'sub-stroke0001',
18
+ diceScore: 0.847,
19
+ volumeMl: 15.32,
20
+ elapsedSeconds: 12.5,
21
+ },
22
+ }
src/test/setup.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { cleanup } from '@testing-library/react'
3
+ import { afterEach, beforeAll, afterAll, vi } from 'vitest'
4
+ import { server } from '../mocks/server'
5
+
6
+ // Establish API mocking before all tests
7
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
8
+
9
+ // Clean up after each test
10
+ afterEach(() => {
11
+ cleanup()
12
+ server.resetHandlers()
13
+ })
14
+
15
+ // Clean up after all tests
16
+ afterAll(() => server.close())
17
+
18
+ // Mock ResizeObserver (needed for some UI components)
19
+ global.ResizeObserver = class ResizeObserver {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ }
24
+
25
+ // Mock WebGL2 context for NiiVue
26
+ // NiiVue requires specific extensions for float textures (overlays)
27
+ // See: https://github.com/niivue/niivue#browser-requirements
28
+ const mockExtensions: Record<string, object> = {
29
+ // Required for float textures (overlay rendering)
30
+ EXT_color_buffer_float: {},
31
+ OES_texture_float_linear: {},
32
+ // Required for WebGL context management
33
+ WEBGL_lose_context: {
34
+ loseContext: vi.fn(),
35
+ restoreContext: vi.fn(),
36
+ },
37
+ // Optional but commonly requested
38
+ EXT_texture_filter_anisotropic: {
39
+ TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe,
40
+ MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff,
41
+ },
42
+ WEBGL_debug_renderer_info: {
43
+ UNMASKED_VENDOR_WEBGL: 0x9245,
44
+ UNMASKED_RENDERER_WEBGL: 0x9246,
45
+ },
46
+ }
47
+
48
+ const mockWebGL2Context = {
49
+ canvas: null as HTMLCanvasElement | null,
50
+ drawingBufferWidth: 640,
51
+ drawingBufferHeight: 480,
52
+ createShader: vi.fn(() => ({})),
53
+ shaderSource: vi.fn(),
54
+ compileShader: vi.fn(),
55
+ getShaderParameter: vi.fn(() => true),
56
+ getShaderInfoLog: vi.fn(() => ''),
57
+ createProgram: vi.fn(() => ({})),
58
+ attachShader: vi.fn(),
59
+ linkProgram: vi.fn(),
60
+ getProgramParameter: vi.fn(() => true),
61
+ getProgramInfoLog: vi.fn(() => ''),
62
+ useProgram: vi.fn(),
63
+ getAttribLocation: vi.fn(() => 0),
64
+ getUniformLocation: vi.fn(() => ({})),
65
+ createBuffer: vi.fn(() => ({})),
66
+ bindBuffer: vi.fn(),
67
+ bufferData: vi.fn(),
68
+ enableVertexAttribArray: vi.fn(),
69
+ vertexAttribPointer: vi.fn(),
70
+ createTexture: vi.fn(() => ({})),
71
+ bindTexture: vi.fn(),
72
+ texParameteri: vi.fn(),
73
+ texParameterf: vi.fn(),
74
+ texImage2D: vi.fn(),
75
+ texImage3D: vi.fn(),
76
+ texStorage2D: vi.fn(),
77
+ texStorage3D: vi.fn(),
78
+ texSubImage2D: vi.fn(),
79
+ texSubImage3D: vi.fn(),
80
+ activeTexture: vi.fn(),
81
+ generateMipmap: vi.fn(),
82
+ uniform1i: vi.fn(),
83
+ uniform1f: vi.fn(),
84
+ uniform2f: vi.fn(),
85
+ uniform2fv: vi.fn(),
86
+ uniform3f: vi.fn(),
87
+ uniform3fv: vi.fn(),
88
+ uniform4f: vi.fn(),
89
+ uniform4fv: vi.fn(),
90
+ uniformMatrix4fv: vi.fn(),
91
+ viewport: vi.fn(),
92
+ scissor: vi.fn(),
93
+ clear: vi.fn(),
94
+ clearColor: vi.fn(),
95
+ clearDepth: vi.fn(),
96
+ enable: vi.fn(),
97
+ disable: vi.fn(),
98
+ blendFunc: vi.fn(),
99
+ blendFuncSeparate: vi.fn(),
100
+ depthFunc: vi.fn(),
101
+ depthMask: vi.fn(),
102
+ cullFace: vi.fn(),
103
+ drawArrays: vi.fn(),
104
+ drawElements: vi.fn(),
105
+ // CRITICAL: Return stub extensions for NiiVue float texture support
106
+ getExtension: vi.fn((name: string) => mockExtensions[name] || null),
107
+ getParameter: vi.fn((pname: number) => {
108
+ // Return reasonable defaults for common parameter queries
109
+ if (pname === 0x0d33) return 16384 // MAX_TEXTURE_SIZE
110
+ if (pname === 0x8073) return 2048 // MAX_3D_TEXTURE_SIZE
111
+ if (pname === 0x851c) return 16 // MAX_TEXTURE_IMAGE_UNITS
112
+ return 0
113
+ }),
114
+ getSupportedExtensions: vi.fn(() => Object.keys(mockExtensions)),
115
+ pixelStorei: vi.fn(),
116
+ readPixels: vi.fn(),
117
+ createFramebuffer: vi.fn(() => ({})),
118
+ bindFramebuffer: vi.fn(),
119
+ framebufferTexture2D: vi.fn(),
120
+ checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE
121
+ createRenderbuffer: vi.fn(() => ({})),
122
+ bindRenderbuffer: vi.fn(),
123
+ renderbufferStorage: vi.fn(),
124
+ framebufferRenderbuffer: vi.fn(),
125
+ deleteTexture: vi.fn(),
126
+ deleteBuffer: vi.fn(),
127
+ deleteProgram: vi.fn(),
128
+ deleteShader: vi.fn(),
129
+ deleteFramebuffer: vi.fn(),
130
+ deleteRenderbuffer: vi.fn(),
131
+ createVertexArray: vi.fn(() => ({})),
132
+ bindVertexArray: vi.fn(),
133
+ deleteVertexArray: vi.fn(),
134
+ flush: vi.fn(),
135
+ finish: vi.fn(),
136
+ isContextLost: vi.fn(() => false),
137
+ }
138
+
139
+ // Override getContext to return WebGL mock - uses type assertion for test mocking
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ ;(HTMLCanvasElement.prototype as any).getContext = function (
142
+ contextType: string
143
+ ): RenderingContext | null {
144
+ if (contextType === 'webgl2' || contextType === 'webgl') {
145
+ return {
146
+ ...mockWebGL2Context,
147
+ canvas: this,
148
+ } as unknown as WebGL2RenderingContext
149
+ }
150
+ return null
151
+ }
src/types/index.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Metrics {
2
+ caseId: string
3
+ diceScore: number | null
4
+ volumeMl: number | null
5
+ elapsedSeconds: number
6
+ }
7
+
8
+ export interface SegmentationResult {
9
+ dwiUrl: string
10
+ predictionUrl: string
11
+ metrics: Metrics
12
+ }
13
+
14
+ export interface CasesResponse {
15
+ cases: string[]
16
+ }
17
+
18
+ export interface SegmentResponse {
19
+ caseId: string
20
+ diceScore: number | null
21
+ volumeMl: number | null
22
+ elapsedSeconds: number
23
+ dwiUrl: string
24
+ predictionUrl: string
25
+ }
tsconfig.app.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"],
28
+ "exclude": ["src/test", "src/mocks", "src/**/*.test.tsx", "src/**/*.test.ts"]
29
+ }
tsconfig.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" },
6
+ { "path": "./tsconfig.test.json" }
7
+ ]
8
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts", "vitest.config.ts"]
26
+ }
tsconfig.test.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom", "node"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src/test", "src/mocks", "src/**/*.test.tsx", "src/**/*.test.ts"]
28
+ }
vite.config.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ export default defineConfig({
6
+ plugins: [react(), tailwindcss()],
7
+ build: {
8
+ outDir: 'dist',
9
+ },
10
+ })
vitest.config.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, mergeConfig } from 'vitest/config'
2
+ import viteConfig from './vite.config'
3
+
4
+ export default mergeConfig(
5
+ viteConfig,
6
+ defineConfig({
7
+ test: {
8
+ globals: true,
9
+ environment: 'jsdom',
10
+ setupFiles: ['./src/test/setup.ts'],
11
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
12
+ exclude: ['node_modules', 'e2e'],
13
+ coverage: {
14
+ provider: 'v8',
15
+ reporter: ['text', 'json', 'html'],
16
+ include: ['src/**/*.{ts,tsx}'],
17
+ exclude: [
18
+ 'src/**/*.test.{ts,tsx}',
19
+ 'src/test/**',
20
+ 'src/mocks/**',
21
+ 'src/main.tsx',
22
+ 'src/vite-env.d.ts',
23
+ ],
24
+ thresholds: {
25
+ statements: 80,
26
+ branches: 75,
27
+ functions: 80,
28
+ lines: 80,
29
+ },
30
+ },
31
+ },
32
+ })
33
+ )