VibecoderMcSwaggins Claude commited on
Commit
497bb49
·
1 Parent(s): fe12d1c

feat(api): async job queue with comprehensive test coverage (#36)

Browse files

* docs(bugs): add gateway timeout audit and deployment checklist

- Document Bug 003: HF Spaces ~60s proxy timeout risk for ML inference
- Add comprehensive deployment checklist with verification status
- Include E2E flow audit diagram for debugging reference
- Verify existing bug fixes (001, 002) are correct and complete

* feat(api): async job queue to eliminate gateway timeout

Implement async job queue pattern to handle HuggingFace Spaces' ~60s
gateway timeout for long-running ML inference (30-60s typical).

## Problem
- HF Spaces proxy has hard ~60s timeout
- DeepISLES inference takes 30-60s
- Intermittent 504 Gateway Timeout errors

## Solution
POST /api/segment now returns 202 Accepted immediately with job ID.
Frontend polls GET /api/jobs/{id} every 2s for status/progress/results.
No single request exceeds the timeout - completely eliminates the issue.

### Backend Changes
- Add job_store.py: Thread-safe in-memory job storage with TTL cleanup
- Update routes.py: Async job creation + background task execution
- Update schemas.py: CreateJobResponse, JobStatusResponse types
- Update main.py: Lifespan handler for job store initialization

### Frontend Changes
- Add ProgressIndicator component with animated progress bar
- Update useSegmentation hook with polling logic
- Update api/client.ts with createSegmentJob/getJobStatus methods
- Update App.tsx to show progress and cancel button
- Update types/index.ts with JobStatus types
- Update mock handlers for testing

### Documentation
- Add docs/specs/async-job-queue.md (full spec)
- Update docs/bugs/003-gateway-timeout-long-inference.md (FIXED)
- Update docs/bugs/README.md (checklist, E2E flow v2.0)

Performance: Initial response <1s (was 30-60s), zero timeout risk

* fix(test): comprehensive test fixes for async job queue

Frontend Test Fixes:
- Remove fake timers from App.test.tsx (incompatible with MSW polling)
- Use real timers with configurable mock job duration (500ms)
- Add setMockJobDuration() to handlers.ts for test configuration
- Fix ambiguous element queries (multiple elements with same text)
- Add proper timeout for multi-run test (15s for two job cycles)
- Use behavior-based assertions instead of implementation details

Backend Tests:
- Add comprehensive unit tests for job_store.py (20 tests)
- Job dataclass tests: elapsed time, to_dict(), status handling
- JobStore tests: create, get, start, update, complete, fail
- Cleanup tests: TTL expiration, file removal
- Global store tests: init/get patterns
- Update test_endpoints.py for async API (11 tests)
- POST /api/segment returns 202 with job ID
- GET /api/jobs/{id} returns status/progress/result
- Error handling tests

All tests now pass:
- Frontend: 63 tests
- Backend: 31 tests (API only)

* fix(lint): remove unused imports and fix type annotation

- Remove unused `time` and `field` imports from job_store.py
- Import AsyncIterator from collections.abc instead of typing
- Prefix unused `app` parameter with underscore in lifespan function

* style: format schemas.py to pass CI format check

* fix(e2e): update fixtures for async job queue API pattern

The e2e fixtures were mocking the old sync API (POST /api/segment returns
result directly). Updated to match the new async job queue pattern:

- POST /api/segment returns 202 with jobId
- GET /api/jobs/:jobId returns job status with progress
- Jobs progress over ~1 second from pending → running → completed
- Fixed processingText locator to use button role (avoid strict mode violation)

* fix(security): apply CodeRabbit security and quality fixes

Security fixes (CRITICAL):
- Add path traversal protection in cleanup_old_jobs()
- Validate job_id format to allow only alphanumeric, hyphens, underscores
- Use full UUID hex instead of truncated 8-char (prevents collisions)
- Sanitize error messages - don't expose raw exceptions to clients

Code quality improvements:
- Use 'is not None' checks in Job.to_dict() instead of truthiness
- Ensure started_at is set before computing elapsed time
- Prevent job_id overwrites with KeyError on duplicate
- Don't log case_id (potentially sensitive medical data)
- Fix misleading comment about lifespan vs import order

Documentation:
- Add 'text' language specifier to code fence blocks
- Note multi-worker would require shared store (Redis/DB)

---------

Co-authored-by: Claude <noreply@anthropic.com>

e2e/fixtures.ts CHANGED
@@ -1,48 +1,147 @@
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
  })
 
1
+ import { test as base, expect, Page } from '@playwright/test'
2
 
3
+ // API response mocks matching the async job queue pattern
4
  const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
5
+
6
+ // Track jobs for the async pattern
7
+ interface MockJob {
8
+ id: string
9
+ caseId: string
10
+ status: 'pending' | 'running' | 'completed' | 'failed'
11
+ progress: number
12
+ progressMessage: string
13
+ createdAt: number
14
+ }
15
+
16
+ // Job store per test (reset for each test)
17
+ const createJobStore = () => {
18
+ const jobs = new Map<string, MockJob>()
19
+ let jobCounter = 0
20
+
21
+ return {
22
+ createJob(caseId: string): MockJob {
23
+ const jobId = `e2e-job-${++jobCounter}`
24
+ const job: MockJob = {
25
+ id: jobId,
26
+ caseId,
27
+ status: 'pending',
28
+ progress: 0,
29
+ progressMessage: 'Job queued',
30
+ createdAt: Date.now(),
31
+ }
32
+ jobs.set(jobId, job)
33
+ return job
34
+ },
35
+ getJob(jobId: string): MockJob | undefined {
36
+ return jobs.get(jobId)
37
+ },
38
+ updateJobProgress(job: MockJob): MockJob {
39
+ // Simulate job progression over 1 second
40
+ const elapsed = Date.now() - job.createdAt
41
+ if (elapsed < 200) {
42
+ return { ...job, status: 'running', progress: 25, progressMessage: 'Loading case data...' }
43
+ } else if (elapsed < 500) {
44
+ return { ...job, status: 'running', progress: 50, progressMessage: 'Running inference...' }
45
+ } else if (elapsed < 800) {
46
+ return { ...job, status: 'running', progress: 75, progressMessage: 'Processing results...' }
47
+ } else {
48
+ return { ...job, status: 'completed', progress: 100, progressMessage: 'Segmentation complete' }
49
+ }
50
+ },
51
+ }
52
+ }
53
+
54
+ // Mock completed job result
55
+ const createMockResult = (caseId: string) => ({
56
+ caseId,
57
  diceScore: 0.847,
58
  volumeMl: 15.32,
59
  elapsedSeconds: 12.5,
60
  // Use real public NIfTI for visual testing (NiiVue demo image)
61
  dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
62
  predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
63
+ })
64
 
65
+ // Setup API mocking for async job queue pattern
66
+ async function setupApiMocks(page: Page) {
67
+ const jobStore = createJobStore()
68
+
69
+ // Mock GET /api/cases
70
+ await page.route('**/api/cases', (route) => {
71
+ route.fulfill({
72
+ status: 200,
73
+ contentType: 'application/json',
74
+ body: JSON.stringify({ cases: MOCK_CASES }),
 
75
  })
76
+ })
77
+
78
+ // Mock POST /api/segment - returns 202 with job ID (async pattern)
79
+ await page.route('**/api/segment', async (route) => {
80
+ const request = route.request()
81
+ const body = JSON.parse(request.postData() || '{}') as { case_id?: string }
82
+ const caseId = body.case_id || 'sub-stroke0001'
83
 
84
+ // Create a new job
85
+ const job = jobStore.createJob(caseId)
 
 
86
 
87
+ // Small delay to simulate network
88
+ await new Promise((r) => setTimeout(r, 50))
89
+
90
+ route.fulfill({
91
+ status: 202,
92
+ contentType: 'application/json',
93
+ body: JSON.stringify({
94
+ jobId: job.id,
95
+ status: 'pending',
96
+ message: `Segmentation job queued for ${caseId}`,
97
+ }),
98
+ })
99
+ })
100
 
101
+ // Mock GET /api/jobs/:jobId - returns job status (for polling)
102
+ await page.route('**/api/jobs/*', async (route) => {
103
+ const url = route.request().url()
104
+ const jobId = url.split('/api/jobs/')[1]
105
+
106
+ const job = jobStore.getJob(jobId)
107
+ if (!job) {
108
  route.fulfill({
109
+ status: 404,
110
  contentType: 'application/json',
111
+ body: JSON.stringify({ detail: `Job not found: ${jobId}` }),
 
 
 
112
  })
113
+ return
114
+ }
115
+
116
+ // Update job progress based on elapsed time
117
+ const updatedJob = jobStore.updateJobProgress(job)
118
+
119
+ const response: Record<string, unknown> = {
120
+ jobId: updatedJob.id,
121
+ status: updatedJob.status,
122
+ progress: updatedJob.progress,
123
+ progressMessage: updatedJob.progressMessage,
124
+ elapsedSeconds: (Date.now() - updatedJob.createdAt) / 1000,
125
+ }
126
+
127
+ // Include result when completed
128
+ if (updatedJob.status === 'completed') {
129
+ response.result = createMockResult(updatedJob.caseId)
130
+ }
131
+
132
+ route.fulfill({
133
+ status: 200,
134
+ contentType: 'application/json',
135
+ body: JSON.stringify(response),
136
  })
137
+ })
138
+ }
139
 
140
+ // Extend base test to include API mocking
141
+ export const test = base.extend({
142
+ // Auto-mock API routes for every test
143
+ page: async ({ page }, use) => {
144
+ await setupApiMocks(page)
145
  await use(page)
146
  },
147
  })
e2e/pages/HomePage.ts CHANGED
@@ -19,7 +19,7 @@ export class HomePage {
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')
 
19
  })
20
  this.caseSelector = page.getByRole('combobox')
21
  this.runButton = page.getByRole('button', { name: /run segmentation/i })
22
+ this.processingText = page.getByRole('button', { name: /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')
src/App.test.tsx CHANGED
@@ -1,8 +1,8 @@
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
@@ -19,6 +19,17 @@ vi.mock('@niivue/niivue', () => ({
19
  }))
20
 
21
  describe('App Integration', () => {
 
 
 
 
 
 
 
 
 
 
 
22
  describe('Initial Render', () => {
23
  it('renders main heading', () => {
24
  render(<App />)
@@ -94,10 +105,11 @@ describe('App Integration', () => {
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
 
@@ -108,12 +120,33 @@ describe('App Integration', () => {
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 () => {
@@ -127,9 +160,13 @@ describe('App Integration', () => {
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 () => {
@@ -143,19 +180,37 @@ describe('App Integration', () => {
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 />)
@@ -171,11 +226,11 @@ describe('App Integration', () => {
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 />)
@@ -197,16 +252,20 @@ describe('App Integration', () => {
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
 
@@ -218,23 +277,34 @@ describe('App Integration', () => {
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
  })
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } 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, setMockJobDuration } from './mocks/handlers'
6
  import App from './App'
7
 
8
  // Mock NiiVue to avoid WebGL in tests
 
19
  }))
20
 
21
  describe('App Integration', () => {
22
+ // Use real timers for integration tests - fake timers don't sync well
23
+ // with MSW's async handlers and polling intervals
24
+ beforeEach(() => {
25
+ // Reset mock job duration to fast for tests
26
+ setMockJobDuration(500) // Jobs complete in 500ms
27
+ })
28
+
29
+ afterEach(() => {
30
+ setMockJobDuration(500) // Reset to default
31
+ })
32
+
33
  describe('Initial Render', () => {
34
  it('renders main heading', () => {
35
  render(<App />)
 
105
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
106
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
107
 
108
+ // Button should show "Processing..." while job is running
109
+ expect(screen.getByRole('button', { name: /processing/i })).toBeInTheDocument()
110
  })
111
 
112
+ it('shows progress indicator during job execution', async () => {
113
  const user = userEvent.setup()
114
  render(<App />)
115
 
 
120
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
121
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
122
 
123
+ // Progress indicator should appear during processing
124
+ await waitFor(() => {
125
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
126
+ })
127
+ })
128
+
129
+ it('displays metrics after successful segmentation', async () => {
130
+ const user = userEvent.setup()
131
+ render(<App />)
132
+
133
  await waitFor(() => {
134
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
135
  })
136
 
137
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
138
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
139
+
140
+ // Wait for job to complete (mock duration is 500ms, polling is 2s)
141
+ // Use 5s timeout to account for polling interval
142
+ await waitFor(
143
+ () => {
144
+ expect(screen.getByText('0.847')).toBeInTheDocument()
145
+ },
146
+ { timeout: 5000 }
147
+ )
148
+
149
  expect(screen.getByText('15.32 mL')).toBeInTheDocument()
 
150
  })
151
 
152
  it('displays viewer after successful segmentation', async () => {
 
160
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
161
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
162
 
163
+ // Wait for job to complete and canvas to render
164
+ await waitFor(
165
+ () => {
166
+ expect(document.querySelector('canvas')).toBeInTheDocument()
167
+ },
168
+ { timeout: 5000 }
169
+ )
170
  })
171
 
172
  it('hides placeholder after successful segmentation', async () => {
 
180
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
181
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
182
 
183
+ // Wait for job to complete
184
+ await waitFor(
185
+ () => {
186
+ expect(screen.getByText('0.847')).toBeInTheDocument()
187
+ },
188
+ { timeout: 5000 }
189
+ )
190
 
191
  expect(
192
  screen.queryByText(/select a case and run segmentation/i)
193
  ).not.toBeInTheDocument()
194
  })
195
+
196
+ it('shows cancel button during processing', async () => {
197
+ const user = userEvent.setup()
198
+ render(<App />)
199
+
200
+ await waitFor(() => {
201
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
202
+ })
203
+
204
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
205
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
206
+
207
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
208
+ })
209
  })
210
 
211
  describe('Error Handling', () => {
212
+ it('shows error when job creation fails', async () => {
213
+ server.use(errorHandlers.segmentCreateError)
214
  const user = userEvent.setup()
215
 
216
  render(<App />)
 
226
  expect(screen.getByRole('alert')).toBeInTheDocument()
227
  })
228
 
229
+ expect(screen.getByText(/failed to create job/i)).toBeInTheDocument()
230
  })
231
 
232
  it('allows retry after error', async () => {
233
+ server.use(errorHandlers.segmentCreateError)
234
  const user = userEvent.setup()
235
 
236
  render(<App />)
 
252
  // Retry
253
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
254
 
255
+ // Wait for job to complete (real timer now)
256
+ await waitFor(
257
+ () => {
258
+ expect(screen.getByText('0.847')).toBeInTheDocument()
259
+ },
260
+ { timeout: 5000 }
261
+ )
262
 
263
  expect(screen.queryByRole('alert')).not.toBeInTheDocument()
264
  })
265
  })
266
 
267
  describe('Multiple Runs', () => {
268
+ it('allows running segmentation on different cases', { timeout: 15000 }, async () => {
269
  const user = userEvent.setup()
270
  render(<App />)
271
 
 
277
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
278
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
279
 
280
+ // Wait for first segmentation to complete - check metrics (Dice Score proves completion)
281
+ await waitFor(
282
+ () => {
283
+ expect(screen.getByText('0.847')).toBeInTheDocument()
284
+ // Button should no longer say "Processing..." after completion
285
+ expect(screen.queryByRole('button', { name: /processing/i })).not.toBeInTheDocument()
286
+ },
287
+ { timeout: 5000 }
288
+ )
289
 
290
  // Second case
291
  await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
292
  await user.click(screen.getByRole('button', { name: /run segmentation/i }))
293
 
294
+ // Wait for second job to complete - check that case ID changed in metrics
295
+ // Note: We look within the metrics container for the case ID to avoid matching dropdown
296
+ await waitFor(
297
+ () => {
298
+ // The metrics panel shows case ID in a span with class "ml-2 font-mono"
299
+ // after the "Case:" label
300
+ const caseLabels = screen.getAllByText(/Case:/i)
301
+ expect(caseLabels.length).toBeGreaterThan(0)
302
+ // The second run should show sub-stroke0002 in the metrics
303
+ const metricsContainer = screen.getByText('Results').closest('div')
304
+ expect(metricsContainer).toHaveTextContent('sub-stroke0002')
305
+ },
306
+ { timeout: 5000 }
307
+ )
308
  })
309
  })
310
  })
src/App.tsx CHANGED
@@ -3,11 +3,22 @@ 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) {
@@ -15,6 +26,9 @@ export default function App() {
15
  }
16
  }
17
 
 
 
 
18
  return (
19
  <Layout>
20
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -35,12 +49,39 @@ export default function App() {
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
 
@@ -54,7 +95,9 @@ export default function App() {
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
  )}
 
3
  import { CaseSelector } from './components/CaseSelector'
4
  import { NiiVueViewer } from './components/NiiVueViewer'
5
  import { MetricsPanel } from './components/MetricsPanel'
6
+ import { ProgressIndicator } from './components/ProgressIndicator'
7
  import { useSegmentation } from './hooks/useSegmentation'
8
 
9
  export default function App() {
10
  const [selectedCase, setSelectedCase] = useState<string | null>(null)
11
+ const {
12
+ result,
13
+ isLoading,
14
+ error,
15
+ jobStatus,
16
+ progress,
17
+ progressMessage,
18
+ elapsedSeconds,
19
+ runSegmentation,
20
+ cancelJob,
21
+ } = useSegmentation()
22
 
23
  const handleRunSegmentation = async () => {
24
  if (selectedCase) {
 
26
  }
27
  }
28
 
29
+ // Show progress indicator when job is active
30
+ const showProgress = isLoading && jobStatus && jobStatus !== 'completed'
31
+
32
  return (
33
  <Layout>
34
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
 
49
  {isLoading ? 'Processing...' : 'Run Segmentation'}
50
  </button>
51
 
52
+ {/* Cancel button when processing */}
53
+ {isLoading && (
54
+ <button
55
+ onClick={cancelJob}
56
+ className="w-full bg-gray-700 hover:bg-gray-600 text-gray-300
57
+ font-medium py-2 px-4 rounded-lg transition-colors text-sm"
58
+ >
59
+ Cancel
60
+ </button>
61
+ )}
62
+
63
+ {/* Progress indicator */}
64
+ {showProgress && (
65
+ <ProgressIndicator
66
+ progress={progress}
67
+ message={progressMessage}
68
+ status={jobStatus}
69
+ elapsedSeconds={elapsedSeconds}
70
+ />
71
+ )}
72
+
73
+ {/* Error display */}
74
+ {error && !isLoading && (
75
+ <div
76
+ role="alert"
77
+ className="bg-red-900/50 text-red-300 p-3 rounded-lg text-sm"
78
+ >
79
+ <p className="font-medium">Error</p>
80
+ <p className="mt-1">{error}</p>
81
  </div>
82
  )}
83
 
84
+ {/* Results metrics */}
85
  {result && <MetricsPanel metrics={result.metrics} />}
86
  </div>
87
 
 
95
  ) : (
96
  <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
97
  <p className="text-gray-400">
98
+ {isLoading
99
+ ? 'Processing segmentation...'
100
+ : 'Select a case and run segmentation to view results'}
101
  </p>
102
  </div>
103
  )}
src/api/__tests__/client.test.ts CHANGED
@@ -25,37 +25,52 @@ describe('apiClient', () => {
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
  })
 
25
  })
26
  })
27
 
28
+ describe('createSegmentJob', () => {
29
+ it('returns job ID and pending status', async () => {
30
+ const result = await apiClient.createSegmentJob('sub-stroke0001')
31
 
32
+ expect(result.jobId).toBeDefined()
33
+ expect(result.status).toBe('pending')
34
+ expect(result.message).toContain('sub-stroke0001')
 
 
35
  })
36
 
37
+ it('sends fast_mode parameter', async () => {
38
+ const result = await apiClient.createSegmentJob('sub-stroke0001', false)
39
 
40
+ expect(result.jobId).toBeDefined()
41
+ expect(result.status).toBe('pending')
42
  })
43
 
44
+ it('throws ApiError on server error', async () => {
45
+ server.use(errorHandlers.segmentCreateError)
46
 
47
+ await expect(
48
+ apiClient.createSegmentJob('sub-stroke0001')
49
+ ).rejects.toThrow(/failed to create job/i)
50
  })
51
+ })
52
 
53
+ describe('getJobStatus', () => {
54
+ it('returns job status with progress', async () => {
55
+ // First create a job
56
+ const createResult = await apiClient.createSegmentJob('sub-stroke0001')
57
+
58
+ // Then get its status
59
+ const status = await apiClient.getJobStatus(createResult.jobId)
60
+
61
+ expect(status.jobId).toBe(createResult.jobId)
62
+ expect(['pending', 'running', 'completed']).toContain(status.status)
63
+ expect(status.progress).toBeGreaterThanOrEqual(0)
64
+ expect(status.progress).toBeLessThanOrEqual(100)
65
+ expect(status.progressMessage).toBeDefined()
66
+ })
67
+
68
+ it('throws ApiError when job not found', async () => {
69
+ server.use(errorHandlers.jobNotFound)
70
 
71
  await expect(
72
+ apiClient.getJobStatus('nonexistent-job')
73
+ ).rejects.toThrow(/not found/i)
74
  })
75
  })
76
  })
src/api/client.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { CasesResponse, SegmentResponse } from '../types'
 
 
 
 
2
 
3
  function getApiBase(): string {
4
  const url = import.meta.env.VITE_API_URL
@@ -36,6 +40,9 @@ class ApiClient {
36
  this.baseUrl = baseUrl
37
  }
38
 
 
 
 
39
  async getCases(signal?: AbortSignal): Promise<CasesResponse> {
40
  const response = await fetch(`${this.baseUrl}/api/cases`, { signal })
41
 
@@ -51,11 +58,17 @@ class ApiClient {
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: {
@@ -71,7 +84,42 @@ class ApiClient {
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
  )
 
1
+ import type {
2
+ CasesResponse,
3
+ CreateJobResponse,
4
+ JobStatusResponse,
5
+ } from '../types'
6
 
7
  function getApiBase(): string {
8
  const url = import.meta.env.VITE_API_URL
 
40
  this.baseUrl = baseUrl
41
  }
42
 
43
+ /**
44
+ * Get list of available cases
45
+ */
46
  async getCases(signal?: AbortSignal): Promise<CasesResponse> {
47
  const response = await fetch(`${this.baseUrl}/api/cases`, { signal })
48
 
 
58
  return response.json()
59
  }
60
 
61
+ /**
62
+ * Create a segmentation job (async - returns immediately with job ID)
63
+ *
64
+ * The actual ML inference runs in the background. Poll getJobStatus()
65
+ * to track progress and retrieve results when complete.
66
+ */
67
+ async createSegmentJob(
68
  caseId: string,
69
  fastMode: boolean = true,
70
  signal?: AbortSignal
71
+ ): Promise<CreateJobResponse> {
72
  const response = await fetch(`${this.baseUrl}/api/segment`, {
73
  method: 'POST',
74
  headers: {
 
84
  if (!response.ok) {
85
  const error = await response.json().catch(() => ({}))
86
  throw new ApiError(
87
+ `Failed to create job: ${error.detail || response.statusText}`,
88
+ response.status,
89
+ error.detail
90
+ )
91
+ }
92
+
93
+ return response.json()
94
+ }
95
+
96
+ /**
97
+ * Get the status of a segmentation job
98
+ *
99
+ * Poll this endpoint to track progress and retrieve results.
100
+ * When status is 'completed', the result field contains segmentation data.
101
+ * When status is 'failed', the error field contains the error message.
102
+ */
103
+ async getJobStatus(
104
+ jobId: string,
105
+ signal?: AbortSignal
106
+ ): Promise<JobStatusResponse> {
107
+ const response = await fetch(`${this.baseUrl}/api/jobs/${jobId}`, {
108
+ signal,
109
+ })
110
+
111
+ if (response.status === 404) {
112
+ throw new ApiError(
113
+ 'Job not found or expired',
114
+ 404,
115
+ 'Jobs expire after 1 hour'
116
+ )
117
+ }
118
+
119
+ if (!response.ok) {
120
+ const error = await response.json().catch(() => ({}))
121
+ throw new ApiError(
122
+ `Failed to get job status: ${error.detail || response.statusText}`,
123
  response.status,
124
  error.detail
125
  )
src/components/ProgressIndicator.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { JobStatus } from '../types'
2
+
3
+ interface ProgressIndicatorProps {
4
+ progress: number
5
+ message: string
6
+ status: JobStatus
7
+ elapsedSeconds?: number
8
+ }
9
+
10
+ /**
11
+ * Visual progress indicator for long-running ML inference jobs.
12
+ *
13
+ * Shows:
14
+ * - Progress bar with percentage
15
+ * - Current operation message
16
+ * - Elapsed time
17
+ * - Status-appropriate coloring (blue for running, red for failed)
18
+ */
19
+ export function ProgressIndicator({
20
+ progress,
21
+ message,
22
+ status,
23
+ elapsedSeconds,
24
+ }: ProgressIndicatorProps) {
25
+ const isError = status === 'failed'
26
+ const isComplete = status === 'completed'
27
+
28
+ // Determine bar color based on status
29
+ const barColorClass = isError
30
+ ? 'bg-red-500'
31
+ : isComplete
32
+ ? 'bg-green-500'
33
+ : 'bg-blue-500'
34
+
35
+ // Animate the bar while running
36
+ const animationClass =
37
+ status === 'running' || status === 'pending' ? 'animate-pulse' : ''
38
+
39
+ return (
40
+ <div className="bg-gray-800 rounded-lg p-4 space-y-3">
41
+ {/* Header with message and percentage */}
42
+ <div className="flex justify-between items-center text-sm">
43
+ <span className="text-gray-300 font-medium">{message}</span>
44
+ <span className="text-gray-400 tabular-nums">{progress}%</span>
45
+ </div>
46
+
47
+ {/* Progress bar */}
48
+ <div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden">
49
+ <div
50
+ className={`h-full rounded-full transition-all duration-500 ease-out ${barColorClass} ${animationClass}`}
51
+ style={{ width: `${progress}%` }}
52
+ role="progressbar"
53
+ aria-valuenow={progress}
54
+ aria-valuemin={0}
55
+ aria-valuemax={100}
56
+ aria-label={message}
57
+ />
58
+ </div>
59
+
60
+ {/* Footer with elapsed time and status */}
61
+ <div className="flex justify-between items-center text-xs text-gray-500">
62
+ {elapsedSeconds !== undefined ? (
63
+ <span className="tabular-nums">
64
+ Elapsed: {elapsedSeconds.toFixed(1)}s
65
+ </span>
66
+ ) : (
67
+ <span>Starting...</span>
68
+ )}
69
+
70
+ <span
71
+ className={`capitalize ${
72
+ isError
73
+ ? 'text-red-400'
74
+ : isComplete
75
+ ? 'text-green-400'
76
+ : 'text-blue-400'
77
+ }`}
78
+ >
79
+ {status}
80
+ </span>
81
+ </div>
82
+ </div>
83
+ )
84
+ }
src/components/index.ts CHANGED
@@ -2,3 +2,4 @@ export { Layout } from './Layout'
2
  export { MetricsPanel } from './MetricsPanel'
3
  export { CaseSelector } from './CaseSelector'
4
  export { NiiVueViewer } from './NiiVueViewer'
 
 
2
  export { MetricsPanel } from './MetricsPanel'
3
  export { CaseSelector } from './CaseSelector'
4
  export { NiiVueViewer } from './NiiVueViewer'
5
+ export { ProgressIndicator } from './ProgressIndicator'
src/hooks/__tests__/useSegmentation.test.tsx CHANGED
@@ -1,19 +1,28 @@
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(() => {
@@ -22,75 +31,132 @@ describe('useSegmentation', () => {
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
  })
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } 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
+ beforeEach(() => {
9
+ vi.useFakeTimers({ shouldAdvanceTime: true })
10
+ })
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers()
14
+ })
15
+
16
  it('starts with null result and not loading', () => {
17
  const { result } = renderHook(() => useSegmentation())
18
 
19
  expect(result.current.result).toBeNull()
20
  expect(result.current.isLoading).toBe(false)
21
  expect(result.current.error).toBeNull()
22
+ expect(result.current.jobStatus).toBeNull()
23
  })
24
 
25
+ it('sets loading state and job status during segmentation', async () => {
26
  const { result } = renderHook(() => useSegmentation())
27
 
28
  act(() => {
 
31
 
32
  expect(result.current.isLoading).toBe(true)
33
 
34
+ // Wait for job to be created
35
  await waitFor(() => {
36
+ expect(result.current.jobId).toBeDefined()
37
  })
38
+
39
+ expect(result.current.jobStatus).toBeDefined()
40
  })
41
 
42
+ it('returns result on job completion', async () => {
43
  const { result } = renderHook(() => useSegmentation())
44
 
45
+ act(() => {
46
+ result.current.runSegmentation('sub-stroke0001')
47
+ })
48
+
49
+ // Wait for job creation
50
+ await waitFor(() => {
51
+ expect(result.current.jobId).toBeDefined()
52
+ })
53
+
54
+ // Advance time to allow job to complete (mock jobs complete in ~3s)
55
  await act(async () => {
56
+ await vi.advanceTimersByTimeAsync(5000)
57
+ })
58
+
59
+ await waitFor(() => {
60
+ expect(result.current.isLoading).toBe(false)
61
+ expect(result.current.result).not.toBeNull()
62
  })
63
 
 
64
  expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
65
  expect(result.current.result?.metrics.diceScore).toBe(0.847)
66
  expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
67
  })
68
 
69
+ it('shows progress updates during job execution', async () => {
70
+ const { result } = renderHook(() => useSegmentation())
71
+
72
+ act(() => {
73
+ result.current.runSegmentation('sub-stroke0001')
74
+ })
75
+
76
+ // Wait for job to start
77
+ await waitFor(() => {
78
+ expect(result.current.jobId).toBeDefined()
79
+ })
80
+
81
+ // Progress should be tracked
82
+ expect(result.current.progress).toBeGreaterThanOrEqual(0)
83
+ expect(result.current.progressMessage).toBeDefined()
84
+ })
85
+
86
+ it('sets error on job creation failure', async () => {
87
+ server.use(errorHandlers.segmentCreateError)
88
 
89
  const { result } = renderHook(() => useSegmentation())
90
 
91
+ act(() => {
92
+ result.current.runSegmentation('sub-stroke0001')
93
  })
94
 
95
+ await waitFor(() => {
96
+ expect(result.current.isLoading).toBe(false)
97
+ })
98
+
99
+ expect(result.current.error).toMatch(/failed to create job/i)
100
  expect(result.current.result).toBeNull()
101
  })
102
 
103
  it('clears previous error on new request', async () => {
104
+ server.use(errorHandlers.segmentCreateError)
105
  const { result } = renderHook(() => useSegmentation())
106
 
107
  // First request fails
108
+ act(() => {
109
+ result.current.runSegmentation('sub-stroke0001')
110
+ })
111
+
112
+ await waitFor(() => {
113
+ expect(result.current.error).not.toBeNull()
114
  })
 
115
 
116
  // Reset to success handler
117
  server.resetHandlers()
118
 
119
+ // Second request should clear error
120
+ act(() => {
121
+ result.current.runSegmentation('sub-stroke0001')
122
  })
123
 
124
  expect(result.current.error).toBeNull()
125
+ expect(result.current.isLoading).toBe(true)
126
  })
127
 
128
+ it('can cancel a running job', async () => {
129
  const { result } = renderHook(() => useSegmentation())
130
 
131
+ act(() => {
132
+ result.current.runSegmentation('sub-stroke0001')
 
133
  })
 
134
 
135
+ await waitFor(() => {
136
+ expect(result.current.isLoading).toBe(true)
137
+ })
138
+
139
+ // Cancel the job
140
  act(() => {
141
+ result.current.cancelJob()
142
  })
143
 
144
+ expect(result.current.isLoading).toBe(false)
145
+ expect(result.current.jobStatus).toBeNull()
146
+ })
147
+
148
+ it('cleans up polling on unmount', async () => {
149
+ const { result, unmount } = renderHook(() => useSegmentation())
150
+
151
+ act(() => {
152
+ result.current.runSegmentation('sub-stroke0001')
153
+ })
154
+
155
+ await waitFor(() => {
156
+ expect(result.current.isLoading).toBe(true)
157
+ })
158
+
159
+ // Unmount should not throw
160
+ unmount()
161
  })
162
  })
src/hooks/useSegmentation.ts CHANGED
@@ -1,63 +1,205 @@
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
  }
 
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
  import { apiClient } from '../api/client'
3
+ import type { SegmentationResult, JobStatus } from '../types'
4
 
5
+ // Polling interval in milliseconds
6
+ const POLLING_INTERVAL = 2000
7
+
8
+ /**
9
+ * Hook for running segmentation with async job polling.
10
+ *
11
+ * Instead of waiting for the full inference to complete (which can timeout
12
+ * on HuggingFace Spaces), this hook:
13
+ * 1. Creates a job that returns immediately with a job ID
14
+ * 2. Polls for job status/progress every 2 seconds
15
+ * 3. Returns results when the job completes
16
+ *
17
+ * This avoids the ~60s gateway timeout on HF Spaces while providing
18
+ * real-time progress updates to the user.
19
+ */
20
  export function useSegmentation() {
21
+ // Result state
22
  const [result, setResult] = useState<SegmentationResult | null>(null)
 
23
  const [error, setError] = useState<string | null>(null)
24
 
25
+ // Job tracking state
26
+ const [jobId, setJobId] = useState<string | null>(null)
27
+ const [jobStatus, setJobStatus] = useState<JobStatus | null>(null)
28
+ const [progress, setProgress] = useState(0)
29
+ const [progressMessage, setProgressMessage] = useState('')
30
+ const [elapsedSeconds, setElapsedSeconds] = useState<number | undefined>(
31
+ undefined
32
+ )
33
+
34
+ // Loading state - true from job creation until completion/failure
35
+ const [isLoading, setIsLoading] = useState(false)
36
+
37
+ // Refs for managing async operations
38
+ const currentJobRef = useRef<string | null>(null)
39
+ const pollingIntervalRef = useRef<number | null>(null)
40
  const abortControllerRef = useRef<AbortController | null>(null)
41
 
42
+ /**
43
+ * Stop polling for job status
44
+ */
45
+ const stopPolling = useCallback(() => {
46
+ if (pollingIntervalRef.current) {
47
+ clearInterval(pollingIntervalRef.current)
48
+ pollingIntervalRef.current = null
49
+ }
50
+ }, [])
51
+
52
+ /**
53
+ * Poll for job status and update state
54
+ */
55
+ const pollJobStatus = useCallback(
56
+ async (id: string, signal: AbortSignal) => {
57
+ // Don't poll if this isn't the current job
58
+ if (id !== currentJobRef.current) {
59
+ stopPolling()
60
+ return
61
+ }
62
+
63
+ try {
64
+ const response = await apiClient.getJobStatus(id, signal)
65
+
66
+ // Ignore results if job changed
67
+ if (id !== currentJobRef.current) return
68
+
69
+ // Update progress state
70
+ setJobStatus(response.status)
71
+ setProgress(response.progress)
72
+ setProgressMessage(response.progressMessage)
73
+ setElapsedSeconds(response.elapsedSeconds)
74
+
75
+ // Handle completion
76
+ if (response.status === 'completed' && response.result) {
77
+ stopPolling()
78
+ setIsLoading(false)
79
+ setResult({
80
+ dwiUrl: response.result.dwiUrl,
81
+ predictionUrl: response.result.predictionUrl,
82
+ metrics: {
83
+ caseId: response.result.caseId,
84
+ diceScore: response.result.diceScore,
85
+ volumeMl: response.result.volumeMl,
86
+ elapsedSeconds: response.result.elapsedSeconds,
87
+ },
88
+ })
89
+ }
90
+
91
+ // Handle failure
92
+ if (response.status === 'failed') {
93
+ stopPolling()
94
+ setIsLoading(false)
95
+ setError(response.error || 'Job failed')
96
+ setResult(null)
97
+ }
98
+ } catch (err) {
99
+ // Ignore abort errors
100
+ if (err instanceof Error && err.name === 'AbortError') return
101
+
102
+ // Don't stop polling on transient network errors - retry next interval
103
+ console.warn('Polling error (will retry):', err)
104
+ }
105
+ },
106
+ [stopPolling]
107
+ )
108
+
109
+ /**
110
+ * Start segmentation job and begin polling
111
+ */
112
+ const runSegmentation = useCallback(
113
+ async (caseId: string, fastMode = true) => {
114
+ // Cancel any existing job/polling
115
+ stopPolling()
116
+ abortControllerRef.current?.abort()
117
+
118
+ const abortController = new AbortController()
119
+ abortControllerRef.current = abortController
120
+
121
+ // Reset state
122
+ setError(null)
123
  setResult(null)
124
+ setProgress(0)
125
+ setProgressMessage('Creating job...')
126
+ setJobStatus('pending')
127
+ setElapsedSeconds(undefined)
128
+ setIsLoading(true)
129
+
130
+ try {
131
+ // Create the job
132
+ const response = await apiClient.createSegmentJob(
133
+ caseId,
134
+ fastMode,
135
+ abortController.signal
136
+ )
137
+
138
+ // Store job reference
139
+ const newJobId = response.jobId
140
+ setJobId(newJobId)
141
+ currentJobRef.current = newJobId
142
+ setJobStatus(response.status)
143
+ setProgressMessage(response.message)
144
+
145
+ // Start polling
146
+ pollingIntervalRef.current = window.setInterval(() => {
147
+ pollJobStatus(newJobId, abortController.signal)
148
+ }, POLLING_INTERVAL)
149
+
150
+ // Do an initial poll immediately
151
+ await pollJobStatus(newJobId, abortController.signal)
152
+ } catch (err) {
153
+ // Ignore abort errors
154
+ if (err instanceof Error && err.name === 'AbortError') return
155
+
156
+ const message = err instanceof Error ? err.message : 'Failed to start job'
157
+ setError(message)
158
  setIsLoading(false)
159
+ setJobStatus('failed')
160
  }
161
+ },
162
+ [pollJobStatus, stopPolling]
163
+ )
164
+
165
+ /**
166
+ * Cancel the current job (stops polling, clears loading state)
167
+ */
168
+ const cancelJob = useCallback(() => {
169
+ stopPolling()
170
+ abortControllerRef.current?.abort()
171
+ currentJobRef.current = null
172
+ setIsLoading(false)
173
+ setJobStatus(null)
174
+ setProgress(0)
175
+ setProgressMessage('')
176
+ }, [stopPolling])
177
+
178
+ // Cleanup on unmount
179
+ useEffect(() => {
180
+ return () => {
181
+ stopPolling()
182
+ abortControllerRef.current?.abort()
183
  }
184
+ }, [stopPolling])
185
+
186
+ return {
187
+ // Result data
188
+ result,
189
+ error,
190
+
191
+ // Job status
192
+ jobId,
193
+ jobStatus,
194
+ progress,
195
+ progressMessage,
196
+ elapsedSeconds,
197
+
198
+ // Loading state
199
+ isLoading,
200
 
201
+ // Actions
202
+ runSegmentation,
203
+ cancelJob,
204
+ }
205
  }
src/mocks/handlers.ts CHANGED
@@ -1,8 +1,66 @@
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({
@@ -10,18 +68,75 @@ export const handlers = [
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
 
@@ -38,15 +153,54 @@ export const errorHandlers = {
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
  }
 
1
  import { http, HttpResponse, delay } from 'msw'
2
+ import type { JobStatus } from '../types'
3
 
4
  const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
5
 
6
+ // In-memory job store for mocking
7
+ interface MockJob {
8
+ id: string
9
+ caseId: string
10
+ status: JobStatus
11
+ progress: number
12
+ progressMessage: string
13
+ elapsedSeconds: number
14
+ fastMode: boolean
15
+ createdAt: number
16
+ }
17
+
18
+ const mockJobs = new Map<string, MockJob>()
19
+ let jobCounter = 0
20
+
21
+ // Configurable job duration for tests (ms)
22
+ // Default: 500ms for fast tests
23
+ let mockJobDurationMs = 500
24
+
25
+ /**
26
+ * Set the mock job duration for tests.
27
+ * Jobs will complete after this many milliseconds.
28
+ */
29
+ export function setMockJobDuration(durationMs: number): void {
30
+ mockJobDurationMs = durationMs
31
+ }
32
+
33
+ // Simulate job progression over time
34
+ function getJobProgress(job: MockJob): MockJob {
35
+ const elapsed = (Date.now() - job.createdAt) / 1000
36
+ const duration = mockJobDurationMs / 1000 // Convert to seconds
37
+
38
+ if (job.status === 'completed' || job.status === 'failed') {
39
+ return job
40
+ }
41
+
42
+ // Progress through stages based on elapsed time relative to configured duration
43
+ // Stages: 20% loading, 40% inference, 30% processing, 10% finalizing
44
+ const progress20 = duration * 0.2
45
+ const progress60 = duration * 0.6
46
+ const progress90 = duration * 0.9
47
+
48
+ if (elapsed < progress20) {
49
+ return { ...job, status: 'running', progress: 10, progressMessage: 'Loading case data...', elapsedSeconds: elapsed }
50
+ } else if (elapsed < progress60) {
51
+ return { ...job, status: 'running', progress: 30, progressMessage: 'Running DeepISLES inference...', elapsedSeconds: elapsed }
52
+ } else if (elapsed < progress90) {
53
+ return { ...job, status: 'running', progress: 70, progressMessage: 'Processing results...', elapsedSeconds: elapsed }
54
+ } else if (elapsed < duration) {
55
+ return { ...job, status: 'running', progress: 90, progressMessage: 'Computing metrics...', elapsedSeconds: elapsed }
56
+ } else {
57
+ // Job complete
58
+ return { ...job, status: 'completed', progress: 100, progressMessage: 'Segmentation complete', elapsedSeconds: elapsed }
59
+ }
60
+ }
61
+
62
  export const handlers = [
63
+ // GET /api/cases - List available cases
64
  http.get(`${API_BASE}/api/cases`, async () => {
65
  await delay(100)
66
  return HttpResponse.json({
 
68
  })
69
  }),
70
 
71
+ // POST /api/segment - Create segmentation job (returns immediately)
72
  http.post(`${API_BASE}/api/segment`, async ({ request }) => {
73
  const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
74
+ await delay(50) // Small delay to simulate network
75
+
76
+ // Create a new job
77
+ const jobId = `mock-${++jobCounter}`
78
+ const job: MockJob = {
79
+ id: jobId,
80
  caseId: body.case_id,
81
+ status: 'pending',
82
+ progress: 0,
83
+ progressMessage: 'Job queued',
84
+ elapsedSeconds: 0,
85
+ fastMode: body.fast_mode !== false,
86
+ createdAt: Date.now(),
87
+ }
88
+ mockJobs.set(jobId, job)
89
+
90
+ // Return 202 Accepted with job ID
91
+ return HttpResponse.json(
92
+ {
93
+ jobId: jobId,
94
+ status: 'pending',
95
+ message: `Segmentation job queued for ${body.case_id}`,
96
+ },
97
+ { status: 202 }
98
+ )
99
+ }),
100
+
101
+ // GET /api/jobs/:jobId - Get job status
102
+ http.get(`${API_BASE}/api/jobs/:jobId`, async ({ params }) => {
103
+ const jobId = params.jobId as string
104
+ await delay(50) // Small delay to simulate network
105
+
106
+ const job = mockJobs.get(jobId)
107
+ if (!job) {
108
+ return HttpResponse.json(
109
+ { detail: `Job not found: ${jobId}. Jobs expire after 1 hour.` },
110
+ { status: 404 }
111
+ )
112
+ }
113
+
114
+ // Update job progress based on elapsed time
115
+ const updatedJob = getJobProgress(job)
116
+ mockJobs.set(jobId, updatedJob)
117
+
118
+ // Build response
119
+ const response: Record<string, unknown> = {
120
+ jobId: updatedJob.id,
121
+ status: updatedJob.status,
122
+ progress: updatedJob.progress,
123
+ progressMessage: updatedJob.progressMessage,
124
+ elapsedSeconds: Math.round(updatedJob.elapsedSeconds * 100) / 100,
125
+ }
126
+
127
+ // Include result if completed
128
+ if (updatedJob.status === 'completed') {
129
+ response.result = {
130
+ caseId: updatedJob.caseId,
131
+ diceScore: 0.847,
132
+ volumeMl: 15.32,
133
+ elapsedSeconds: updatedJob.fastMode ? 12.5 : 45.0,
134
+ dwiUrl: `${API_BASE}/files/${jobId}/${updatedJob.caseId}/dwi.nii.gz`,
135
+ predictionUrl: `${API_BASE}/files/${jobId}/${updatedJob.caseId}/prediction.nii.gz`,
136
+ }
137
+ }
138
+
139
+ return HttpResponse.json(response)
140
  }),
141
  ]
142
 
 
153
  return HttpResponse.error()
154
  }),
155
 
156
+ segmentCreateError: http.post(`${API_BASE}/api/segment`, () => {
157
  return HttpResponse.json(
158
+ { detail: 'Failed to create job: case not found' },
159
+ { status: 400 }
160
  )
161
  }),
162
 
163
+ jobNotFound: http.get(`${API_BASE}/api/jobs/:jobId`, () => {
164
+ return HttpResponse.json(
165
+ { detail: 'Job not found or expired' },
166
+ { status: 404 }
167
+ )
168
  }),
169
+
170
+ // Simulate a job that fails during processing
171
+ jobFailed: [
172
+ http.post(`${API_BASE}/api/segment`, async ({ request }) => {
173
+ const body = (await request.json()) as { case_id: string }
174
+ const jobId = `fail-${++jobCounter}`
175
+ mockJobs.set(jobId, {
176
+ id: jobId,
177
+ caseId: body.case_id,
178
+ status: 'failed',
179
+ progress: 30,
180
+ progressMessage: 'Error occurred',
181
+ elapsedSeconds: 5.2,
182
+ fastMode: true,
183
+ createdAt: Date.now(),
184
+ })
185
+ return HttpResponse.json(
186
+ { jobId, status: 'pending', message: 'Job queued' },
187
+ { status: 202 }
188
+ )
189
+ }),
190
+ http.get(`${API_BASE}/api/jobs/:jobId`, ({ params }) => {
191
+ const jobId = params.jobId as string
192
+ const job = mockJobs.get(jobId)
193
+ if (!job) {
194
+ return HttpResponse.json({ detail: 'Not found' }, { status: 404 })
195
+ }
196
+ return HttpResponse.json({
197
+ jobId: job.id,
198
+ status: 'failed',
199
+ progress: 30,
200
+ progressMessage: 'Error occurred',
201
+ elapsedSeconds: 5.2,
202
+ error: 'Segmentation failed: out of memory',
203
+ })
204
+ }),
205
+ ],
206
  }
src/types/index.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  export interface Metrics {
2
  caseId: string
3
  diceScore: number | null
@@ -5,16 +6,19 @@ export interface Metrics {
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
@@ -23,3 +27,24 @@ export interface SegmentResponse {
23
  dwiUrl: string
24
  predictionUrl: string
25
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Segmentation metrics
2
  export interface Metrics {
3
  caseId: string
4
  diceScore: number | null
 
6
  elapsedSeconds: number
7
  }
8
 
9
+ // Final segmentation result with URLs and metrics
10
  export interface SegmentationResult {
11
  dwiUrl: string
12
  predictionUrl: string
13
  metrics: Metrics
14
  }
15
 
16
+ // API Response Types
17
  export interface CasesResponse {
18
  cases: string[]
19
  }
20
 
21
+ // Segmentation result data (embedded in job response)
22
  export interface SegmentResponse {
23
  caseId: string
24
  diceScore: number | null
 
27
  dwiUrl: string
28
  predictionUrl: string
29
  }
30
+
31
+ // Job Status Types
32
+ export type JobStatus = 'pending' | 'running' | 'completed' | 'failed'
33
+
34
+ // Response from POST /api/segment (job creation)
35
+ export interface CreateJobResponse {
36
+ jobId: string
37
+ status: JobStatus
38
+ message: string
39
+ }
40
+
41
+ // Response from GET /api/jobs/{jobId} (status polling)
42
+ export interface JobStatusResponse {
43
+ jobId: string
44
+ status: JobStatus
45
+ progress: number
46
+ progressMessage: string
47
+ elapsedSeconds?: number
48
+ result?: SegmentResponse
49
+ error?: string
50
+ }