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 +1 -0
- .gitignore +29 -0
- README.md +73 -0
- e2e/error-handling.spec.ts +54 -0
- e2e/fixtures.ts +50 -0
- e2e/home.spec.ts +36 -0
- e2e/pages/HomePage.ts +62 -0
- e2e/segmentation-flow.spec.ts +49 -0
- eslint.config.js +33 -0
- index.html +13 -0
- package-lock.json +0 -0
- package.json +50 -0
- playwright.config.ts +31 -0
- public/vite.svg +1 -0
- src/App.test.tsx +240 -0
- src/App.tsx +65 -0
- src/api/__tests__/client.test.ts +61 -0
- src/api/client.ts +84 -0
- src/api/index.ts +1 -0
- src/assets/react.svg +1 -0
- src/components/CaseSelector.tsx +73 -0
- src/components/Layout.tsx +21 -0
- src/components/MetricsPanel.tsx +45 -0
- src/components/NiiVueViewer.tsx +108 -0
- src/components/__tests__/CaseSelector.test.tsx +112 -0
- src/components/__tests__/Layout.test.tsx +43 -0
- src/components/__tests__/MetricsPanel.test.tsx +67 -0
- src/components/__tests__/NiiVueViewer.test.tsx +160 -0
- src/components/index.ts +4 -0
- src/hooks/__tests__/useSegmentation.test.tsx +96 -0
- src/hooks/index.ts +1 -0
- src/hooks/useSegmentation.ts +63 -0
- src/index.css +1 -0
- src/main.tsx +10 -0
- src/mocks/handlers.ts +52 -0
- src/mocks/server.ts +4 -0
- src/test/fixtures.ts +22 -0
- src/test/setup.ts +151 -0
- src/types/index.ts +25 -0
- tsconfig.app.json +29 -0
- tsconfig.json +8 -0
- tsconfig.node.json +26 -0
- tsconfig.test.json +28 -0
- vite.config.ts +10 -0
- vitest.config.ts +33 -0
.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 |
+
)
|