Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -85,6 +85,28 @@ describe('useCoverageResolver', () => {
|
||||
expect(result.current.coverageDurationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('clears stale coverage as soon as a different selection starts resolving', async () => {
|
||||
const nextBbox = { ...bbox, min_x: 5.1, max_x: 5.2 }
|
||||
const { result, rerender } = renderHook(
|
||||
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
|
||||
{ initialProps: { selection: bbox } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
})
|
||||
expect(result.current.coverage).toEqual(coverageResult)
|
||||
|
||||
rerender({ selection: nextBbox })
|
||||
|
||||
expect(result.current.coverage).toBeNull()
|
||||
expect(result.current.loadingCoverage).toBe(true)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
})
|
||||
expect(mocks.resolveCoverage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('exposes provider failures without retaining stale results', async () => {
|
||||
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
|
||||
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
|
||||
|
||||
@@ -26,11 +26,14 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
// A new AOI must never temporarily display the previous AOI's coverage.
|
||||
// Clear immediately; the debounce only postpones the network request.
|
||||
setCoverage(null)
|
||||
setCoverageError(null)
|
||||
setLoadingCoverage(true)
|
||||
setCoverageDurationMs(null)
|
||||
const timer = window.setTimeout(() => {
|
||||
const startedAt = Date.now()
|
||||
setLoadingCoverage(true)
|
||||
setCoverageError(null)
|
||||
setCoverageDurationMs(null)
|
||||
externalApi.resolveCoverage({
|
||||
projectId,
|
||||
bbox: {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DetectionRunRead, JobRead, YoloPreflightResponse } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listModels: vi.fn(),
|
||||
listModelAssets: vi.fn(),
|
||||
getYoloPreflight: vi.fn(),
|
||||
runAsync: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
listDetections: vi.fn(),
|
||||
getRunGeoJson: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
compareWithReference: vi.fn(),
|
||||
rasterInspect: vi.fn(),
|
||||
rasterTile: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/api', () => ({
|
||||
detectionApi: {
|
||||
listModels: mocks.listModels,
|
||||
listModelAssets: mocks.listModelAssets,
|
||||
getYoloPreflight: mocks.getYoloPreflight,
|
||||
runAsync: mocks.runAsync,
|
||||
listRuns: mocks.listRuns,
|
||||
listDetections: mocks.listDetections,
|
||||
getRunGeoJson: mocks.getRunGeoJson,
|
||||
getRun: mocks.getRun,
|
||||
compareWithReference: mocks.compareWithReference,
|
||||
},
|
||||
datasetsApi: {
|
||||
rasterInspect: mocks.rasterInspect,
|
||||
rasterTile: mocks.rasterTile,
|
||||
upload: mocks.upload,
|
||||
},
|
||||
}))
|
||||
|
||||
import { useDetectionWorkflow } from './useDetectionWorkflow'
|
||||
|
||||
const projectId = 'project-1'
|
||||
const datasetId = 'dataset-1'
|
||||
const jobId = 'job-1'
|
||||
const analysisRunId = 'run-1'
|
||||
|
||||
const completedJob: JobRead = {
|
||||
id: jobId,
|
||||
job_type: 'detection.run',
|
||||
status: 'success',
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
parameters_json: {},
|
||||
result_json: { detection_count: 1 },
|
||||
}
|
||||
|
||||
const persistedRun: DetectionRunRead = {
|
||||
id: analysisRunId,
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
job_id: jobId,
|
||||
analysis_type: 'detection',
|
||||
status: 'success',
|
||||
model_name: 'yolo-configured',
|
||||
parameters_json: {},
|
||||
result_json: { detection_count: 1 },
|
||||
}
|
||||
|
||||
function preflight(acceleratorReady: boolean): YoloPreflightResponse {
|
||||
return {
|
||||
model_id: 'yolo-configured',
|
||||
status: acceleratorReady ? 'ready' : 'accelerator_unavailable',
|
||||
message: acceleratorReady ? 'Gereed' : 'NVIDIA CUDA is niet beschikbaar',
|
||||
checks: {
|
||||
enabled: true,
|
||||
dependencies_available: true,
|
||||
accelerator_ready: acceleratorReady,
|
||||
model_path_set: true,
|
||||
model_file_exists: true,
|
||||
model_load_requested: false,
|
||||
manifest_path_set: true,
|
||||
manifest_valid: true,
|
||||
tile_paths_exist: true,
|
||||
tile_limit_ok: true,
|
||||
},
|
||||
runtime: { dependencies_assumed: false, cuda_available: acceleratorReady },
|
||||
tile_count: 1,
|
||||
max_tiles: 256,
|
||||
will_download_models: false,
|
||||
will_run_inference: acceleratorReady,
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkflow() {
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const view = renderHook(() => useDetectionWorkflow({
|
||||
selectedProjectId: projectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}))
|
||||
return { ...view, loadProjectData }
|
||||
}
|
||||
|
||||
describe('useDetectionWorkflow GPU execution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
|
||||
mocks.listDetections.mockResolvedValue({ items: [], total: 1, truncated: false })
|
||||
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
|
||||
mocks.listModels.mockResolvedValue({
|
||||
models: [{
|
||||
model_id: 'yolo-configured',
|
||||
display_name: 'YOLO',
|
||||
framework: 'ultralytics/pytorch',
|
||||
task_type: 'object_detection',
|
||||
supported_classes: ['building'],
|
||||
configured: true,
|
||||
status: 'configured',
|
||||
limitation_message: '',
|
||||
operator_review_required: true,
|
||||
}],
|
||||
})
|
||||
mocks.listModelAssets.mockResolvedValue({ items: [], total: 0, model_directory: '/models' })
|
||||
})
|
||||
|
||||
it('queues, follows and loads a persisted result without a synchronous inference fallback', async () => {
|
||||
mocks.runAsync.mockResolvedValue(completedJob)
|
||||
const { result, loadProjectData } = renderWorkflow()
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedDetectionDatasetId(datasetId)
|
||||
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.runDetection()
|
||||
})
|
||||
|
||||
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: 'yolo-configured',
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}))
|
||||
expect(result.current.detectionJob?.status).toBe('success')
|
||||
expect(result.current.detectionRunResult).toMatchObject({
|
||||
analysis_run_id: analysisRunId,
|
||||
job_id: jobId,
|
||||
detection_count: 1,
|
||||
status: 'success',
|
||||
})
|
||||
expect(result.current.detectionWorkflowStage).toBe('complete')
|
||||
expect(result.current.detectionRunError).toBeNull()
|
||||
expect(loadProjectData).toHaveBeenCalledWith(projectId)
|
||||
})
|
||||
|
||||
it('blocks the queue when preflight says the NVIDIA accelerator is unavailable', async () => {
|
||||
mocks.getYoloPreflight.mockResolvedValue(preflight(false))
|
||||
const { result } = renderWorkflow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadDetectionModels()
|
||||
})
|
||||
act(() => {
|
||||
result.current.setSelectedDetectionDatasetId(datasetId)
|
||||
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.prepareAndRunDetection()
|
||||
})
|
||||
|
||||
expect(mocks.runAsync).not.toHaveBeenCalled()
|
||||
expect(result.current.detectionWorkflowStage).toBe('failed')
|
||||
expect(result.current.detectionRunError).toContain('NVIDIA CUDA')
|
||||
})
|
||||
|
||||
it('does not let a late run list from another project overwrite the active project', async () => {
|
||||
let resolveOlder!: (value: { items: DetectionRunRead[]; total: number }) => void
|
||||
let resolveNewer!: (value: { items: DetectionRunRead[]; total: number }) => void
|
||||
mocks.listRuns
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedProjectId }) => useDetectionWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
{ initialProps: { selectedProjectId: 'project-1' } },
|
||||
)
|
||||
|
||||
let olderRequest!: Promise<void>
|
||||
let newerRequest!: Promise<void>
|
||||
act(() => { olderRequest = result.current.loadDetectionRuns('project-1') })
|
||||
rerender({ selectedProjectId: 'project-2' })
|
||||
act(() => { newerRequest = result.current.loadDetectionRuns('project-2') })
|
||||
|
||||
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
|
||||
await act(async () => {
|
||||
resolveNewer({ items: [projectTwoRun], total: 1 })
|
||||
await newerRequest
|
||||
})
|
||||
await act(async () => {
|
||||
resolveOlder({ items: [persistedRun], total: 1 })
|
||||
await olderRequest
|
||||
})
|
||||
|
||||
expect(result.current.detectionRuns).toEqual([projectTwoRun])
|
||||
expect(result.current.selectedDetectionRunId).toBe('run-2')
|
||||
})
|
||||
|
||||
it('does not let late detection results from another project overwrite the active project', async () => {
|
||||
type DetectionList = { items: Array<{ id: string }>; total: number; truncated: boolean }
|
||||
type DetectionGeoJson = { type: 'FeatureCollection'; features: Array<{ id: string }> }
|
||||
let resolveOlderList!: (value: DetectionList) => void
|
||||
let resolveNewerList!: (value: DetectionList) => void
|
||||
let resolveOlderGeoJson!: (value: DetectionGeoJson) => void
|
||||
let resolveNewerGeoJson!: (value: DetectionGeoJson) => void
|
||||
mocks.listDetections
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
|
||||
mocks.getRunGeoJson
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeoJson = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeoJson = resolve }))
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedProjectId }) => useDetectionWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
{ initialProps: { selectedProjectId: 'project-1' } },
|
||||
)
|
||||
|
||||
let olderRequest!: Promise<void>
|
||||
let newerRequest!: Promise<void>
|
||||
act(() => { olderRequest = result.current.loadDetectionResults('run-1') })
|
||||
rerender({ selectedProjectId: 'project-2' })
|
||||
act(() => { newerRequest = result.current.loadDetectionResults('run-2') })
|
||||
|
||||
await act(async () => {
|
||||
resolveNewerList({ items: [{ id: 'result-2' }], total: 1, truncated: false })
|
||||
resolveNewerGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-2' }] })
|
||||
await newerRequest
|
||||
})
|
||||
await act(async () => {
|
||||
resolveOlderList({ items: [{ id: 'result-1' }], total: 1, truncated: false })
|
||||
resolveOlderGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-1' }] })
|
||||
await olderRequest
|
||||
})
|
||||
|
||||
expect(result.current.detectionItems).toEqual([{ id: 'result-2' }])
|
||||
expect(result.current.detectionGeoJson).toEqual({
|
||||
type: 'FeatureCollection',
|
||||
features: [{ id: 'feature-2' }],
|
||||
})
|
||||
expect(result.current.loadingDetectionResults).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a late queue response when the user has already changed project', async () => {
|
||||
let resolveQueuedJob!: (value: JobRead) => void
|
||||
mocks.runAsync.mockReturnValue(new Promise((resolve) => { resolveQueuedJob = resolve }))
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedProjectId }) => useDetectionWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
{ initialProps: { selectedProjectId: 'project-1' } },
|
||||
)
|
||||
act(() => {
|
||||
result.current.setSelectedDetectionDatasetId(datasetId)
|
||||
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
|
||||
})
|
||||
|
||||
let request!: Promise<void>
|
||||
act(() => { request = result.current.runDetection() })
|
||||
rerender({ selectedProjectId: 'project-2' })
|
||||
await act(async () => {
|
||||
resolveQueuedJob(completedJob)
|
||||
await request
|
||||
})
|
||||
|
||||
expect(result.current.detectionJob).toBeNull()
|
||||
expect(result.current.detectionRunResult).toBeNull()
|
||||
expect(result.current.runningDetection).toBe(false)
|
||||
expect(mocks.getRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { datasetsApi, detectionApi } from '../services/api'
|
||||
import type {
|
||||
DatasetCreateResponse,
|
||||
@@ -13,6 +13,12 @@ import type {
|
||||
YoloPreflightResponse,
|
||||
} from '../types'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import {
|
||||
analysisRunIdFromJob,
|
||||
completedDetectionResponse,
|
||||
DetectionJobError,
|
||||
waitForDetectionJob,
|
||||
} from '../services/detectionJob'
|
||||
|
||||
interface DetectionWorkflowOptions {
|
||||
selectedProjectId: string | null
|
||||
@@ -88,6 +94,16 @@ function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, ov
|
||||
return Math.ceil(width / step) * Math.ceil(height / step)
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
function abortedError(): Error {
|
||||
const error = new Error('Het volgen van de detectietaak is gestopt')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function useDetectionWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets,
|
||||
@@ -106,6 +122,7 @@ export function useDetectionWorkflow({
|
||||
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
|
||||
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
|
||||
const [runningDetection, setRunningDetection] = useState(false)
|
||||
const [detectionJob, setDetectionJob] = useState<JobRead | null>(null)
|
||||
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
|
||||
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
|
||||
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
|
||||
@@ -130,6 +147,36 @@ export function useDetectionWorkflow({
|
||||
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
|
||||
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
|
||||
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
|
||||
const activeDetectionControllerRef = useRef<AbortController | null>(null)
|
||||
const selectedProjectIdRef = useRef(selectedProjectId)
|
||||
const detectionExecutionSequence = useRef(0)
|
||||
const detectionRunsRequestSequence = useRef(0)
|
||||
const detectionResultsRequestSequence = useRef(0)
|
||||
const detectionQaRequestSequence = useRef(0)
|
||||
const detectionCalibrationSequence = useRef(0)
|
||||
selectedProjectIdRef.current = selectedProjectId
|
||||
|
||||
useEffect(() => {
|
||||
activeDetectionControllerRef.current?.abort()
|
||||
activeDetectionControllerRef.current = null
|
||||
detectionExecutionSequence.current += 1
|
||||
detectionQaRequestSequence.current += 1
|
||||
detectionCalibrationSequence.current += 1
|
||||
setDetectionJob(null)
|
||||
setRunningDetection(false)
|
||||
setDetectionRunResult(null)
|
||||
setDetectionRunError(null)
|
||||
setDetectionWorkflowStage('idle')
|
||||
setDetectionQaResult(null)
|
||||
setDetectionQaError(null)
|
||||
setRunningDetectionQa(false)
|
||||
setDetectionCalibrationRows([])
|
||||
setDetectionCalibrationError(null)
|
||||
setRunningDetectionCalibration(false)
|
||||
return () => {
|
||||
activeDetectionControllerRef.current?.abort()
|
||||
}
|
||||
}, [selectedProjectId])
|
||||
|
||||
const loadDetectionModels = async () => {
|
||||
setLoadingDetectionModels(true)
|
||||
@@ -187,26 +234,36 @@ export function useDetectionWorkflow({
|
||||
}
|
||||
|
||||
const loadDetectionRuns = async (projectId = selectedProjectId) => {
|
||||
const sequence = detectionRunsRequestSequence.current + 1
|
||||
detectionRunsRequestSequence.current = sequence
|
||||
if (!projectId) {
|
||||
setDetectionRuns([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await detectionApi.listRuns({ project_id: projectId })
|
||||
if (
|
||||
detectionRunsRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) return
|
||||
setDetectionRuns(response.items)
|
||||
if (!selectedDetectionRunId && response.items.length > 0) {
|
||||
setSelectedDetectionRunId(response.items[0].id)
|
||||
}
|
||||
setSelectedDetectionRunId((current) => current || response.items[0]?.id || '')
|
||||
} catch (error) {
|
||||
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
|
||||
if (
|
||||
detectionRunsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
|
||||
if (!analysisRunId) {
|
||||
const sequence = detectionResultsRequestSequence.current + 1
|
||||
detectionResultsRequestSequence.current = sequence
|
||||
const requestProjectId = selectedProjectIdRef.current
|
||||
if (!analysisRunId || !requestProjectId) {
|
||||
setDetectionItems([])
|
||||
setDetectionTotal(0)
|
||||
setDetectionTruncated(false)
|
||||
setDetectionTotal(0)
|
||||
setDetectionTruncated(false)
|
||||
setDetectionGeoJson(null)
|
||||
@@ -216,7 +273,7 @@ export function useDetectionWorkflow({
|
||||
setDetectionRunError(null)
|
||||
try {
|
||||
const params = {
|
||||
project_id: selectedProjectId ?? '',
|
||||
project_id: requestProjectId,
|
||||
class_name: detectionClassFilter || null,
|
||||
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
|
||||
}
|
||||
@@ -224,14 +281,28 @@ export function useDetectionWorkflow({
|
||||
detectionApi.listDetections(analysisRunId, params),
|
||||
detectionApi.getRunGeoJson(analysisRunId, params),
|
||||
])
|
||||
if (
|
||||
detectionResultsRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== requestProjectId
|
||||
) return
|
||||
setDetectionItems(detectionsResponse.items)
|
||||
setDetectionTotal(detectionsResponse.total)
|
||||
setDetectionTruncated(Boolean(detectionsResponse.truncated))
|
||||
setDetectionGeoJson(geoJsonResponse)
|
||||
} catch (error) {
|
||||
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
|
||||
if (
|
||||
detectionResultsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === requestProjectId
|
||||
) {
|
||||
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
|
||||
}
|
||||
} finally {
|
||||
setLoadingDetectionResults(false)
|
||||
if (
|
||||
detectionResultsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === requestProjectId
|
||||
) {
|
||||
setLoadingDetectionResults(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,23 +312,91 @@ export function useDetectionWorkflow({
|
||||
manifestPath: string | null,
|
||||
modelId = selectedDetectionModelId,
|
||||
modelAssetId = selectedModelAssetId,
|
||||
confidenceThreshold = detectionConfidenceThreshold,
|
||||
parametersJson: Record<string, unknown> = {},
|
||||
) => {
|
||||
const result = await detectionApi.run({
|
||||
if (
|
||||
(activeDetectionControllerRef.current && !activeDetectionControllerRef.current.signal.aborted)
|
||||
|| detectionJob?.status === 'queued'
|
||||
|| detectionJob?.status === 'running'
|
||||
) {
|
||||
throw new DetectionJobError(
|
||||
'Er wordt al een GPU-detectietaak gevolgd. Wacht tot die taak klaar is voordat u een nieuwe start.',
|
||||
'DETECTION_JOB_ALREADY_ACTIVE',
|
||||
detectionJob?.id ?? 'unknown',
|
||||
)
|
||||
}
|
||||
|
||||
const request = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: modelId,
|
||||
model_asset_id: modelAssetId || null,
|
||||
confidence_threshold: detectionConfidenceThreshold,
|
||||
confidence_threshold: confidenceThreshold,
|
||||
tile_manifest_path: manifestPath,
|
||||
parameters_json: {},
|
||||
})
|
||||
setDetectionRunResult(result)
|
||||
setSelectedDetectionRunId(result.analysis_run_id)
|
||||
setDetectionWorkflowStage('loading')
|
||||
await loadDetectionRuns(projectId)
|
||||
await loadDetectionResults(result.analysis_run_id)
|
||||
await loadProjectData(projectId)
|
||||
return result
|
||||
parameters_json: parametersJson,
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const executionSequence = detectionExecutionSequence.current + 1
|
||||
detectionExecutionSequence.current = executionSequence
|
||||
activeDetectionControllerRef.current = controller
|
||||
const assertExecutionCurrent = () => {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| detectionExecutionSequence.current !== executionSequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) {
|
||||
throw abortedError()
|
||||
}
|
||||
}
|
||||
try {
|
||||
setDetectionJob(null)
|
||||
const queuedJob = await detectionApi.runAsync(request)
|
||||
assertExecutionCurrent()
|
||||
setDetectionJob(queuedJob)
|
||||
const completedJob = await waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob: queuedJob,
|
||||
signal: controller.signal,
|
||||
onStatus: (job) => {
|
||||
if (
|
||||
detectionExecutionSequence.current === executionSequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setDetectionJob(job)
|
||||
}
|
||||
},
|
||||
})
|
||||
assertExecutionCurrent()
|
||||
const explicitAnalysisRunId = analysisRunIdFromJob(completedJob)
|
||||
const run = explicitAnalysisRunId
|
||||
? await detectionApi.getRun(explicitAnalysisRunId, projectId)
|
||||
: (await detectionApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
|
||||
.find((candidate) => candidate.job_id === completedJob.id)
|
||||
assertExecutionCurrent()
|
||||
if (!run) {
|
||||
throw new DetectionJobError(
|
||||
'De GPU-taak is voltooid, maar de bijbehorende bewaarde detectierun ontbreekt.',
|
||||
'DETECTION_RUN_RESULT_NOT_FOUND',
|
||||
completedJob.id,
|
||||
)
|
||||
}
|
||||
const result = completedDetectionResponse(request, completedJob, run)
|
||||
setDetectionRunResult(result)
|
||||
setSelectedDetectionRunId(result.analysis_run_id)
|
||||
setDetectionWorkflowStage('loading')
|
||||
await loadDetectionRuns(projectId)
|
||||
assertExecutionCurrent()
|
||||
await loadDetectionResults(result.analysis_run_id)
|
||||
assertExecutionCurrent()
|
||||
await loadProjectData(projectId)
|
||||
assertExecutionCurrent()
|
||||
return result
|
||||
} finally {
|
||||
if (activeDetectionControllerRef.current === controller) {
|
||||
activeDetectionControllerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runDetection = async () => {
|
||||
@@ -265,6 +404,7 @@ export function useDetectionWorkflow({
|
||||
setDetectionRunError('Kies eerst een werkruimte')
|
||||
return
|
||||
}
|
||||
const projectId = selectedProjectId
|
||||
const datasetId = selectedDetectionDatasetId
|
||||
if (!datasetId) {
|
||||
setDetectionRunError('Kies eerst een rasterbron')
|
||||
@@ -275,13 +415,19 @@ export function useDetectionWorkflow({
|
||||
setRunningDetection(true)
|
||||
setDetectionWorkflowStage('detecting')
|
||||
try {
|
||||
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null)
|
||||
setDetectionWorkflowStage('complete')
|
||||
await executeDetection(projectId, datasetId, detectionTileManifestPath.trim() || null)
|
||||
if (selectedProjectIdRef.current === projectId) {
|
||||
setDetectionWorkflowStage('complete')
|
||||
}
|
||||
} catch (error) {
|
||||
setDetectionRunError(formatError(error, 'Detection run failed'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||
setDetectionRunError(formatError(error, 'Detection run failed'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
}
|
||||
} finally {
|
||||
setRunningDetection(false)
|
||||
if (selectedProjectIdRef.current === projectId) {
|
||||
setRunningDetection(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,10 +436,11 @@ export function useDetectionWorkflow({
|
||||
setDetectionRunError('De regionale werkruimte is nog niet geladen')
|
||||
return false
|
||||
}
|
||||
const projectId = selectedProjectId
|
||||
setDetectionRunError(null)
|
||||
setDetectionWorkflowStage('uploading')
|
||||
try {
|
||||
const dataset = await datasetsApi.upload(selectedProjectId, {
|
||||
const dataset = await datasetsApi.upload(projectId, {
|
||||
file,
|
||||
datasetType: 'raster',
|
||||
source: 'user_upload',
|
||||
@@ -302,15 +449,19 @@ export function useDetectionWorkflow({
|
||||
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
|
||||
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
|
||||
})
|
||||
if (selectedProjectIdRef.current !== projectId) throw abortedError()
|
||||
setSelectedDetectionDatasetId(dataset.id)
|
||||
setDetectionTileManifestPath('')
|
||||
setDetectionRunResult(null)
|
||||
setDetectionWorkflowStage('ready')
|
||||
await loadProjectData(selectedProjectId)
|
||||
await loadProjectData(projectId)
|
||||
if (selectedProjectIdRef.current !== projectId) throw abortedError()
|
||||
return true
|
||||
} catch (error) {
|
||||
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -323,6 +474,10 @@ export function useDetectionWorkflow({
|
||||
setDetectionRunError('De regionale werkruimte is nog niet geladen')
|
||||
return null
|
||||
}
|
||||
const projectId = selectedProjectId
|
||||
const assertProjectCurrent = () => {
|
||||
if (selectedProjectIdRef.current !== projectId) throw abortedError()
|
||||
}
|
||||
const datasetId = datasetIdOverride || selectedDetectionDatasetId
|
||||
if (!datasetId) {
|
||||
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
|
||||
@@ -334,7 +489,7 @@ export function useDetectionWorkflow({
|
||||
: selectedModelAssetId
|
||||
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
|
||||
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
|
||||
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
|
||||
setDetectionRunError('Het gekozen productie-analysemodel is niet beschikbaar; vernieuw de modelstatus en controleer de serverconfiguratie')
|
||||
return null
|
||||
}
|
||||
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
|
||||
@@ -349,7 +504,8 @@ export function useDetectionWorkflow({
|
||||
let manifestPath = detectionTileManifestPath.trim()
|
||||
if (!manifestPath) {
|
||||
setDetectionWorkflowStage('tiling')
|
||||
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId)
|
||||
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
|
||||
assertProjectCurrent()
|
||||
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
|
||||
const maxTiles = yoloPreflight?.max_tiles ?? 256
|
||||
if (expectedTileCount === null) {
|
||||
@@ -360,10 +516,11 @@ export function useDetectionWorkflow({
|
||||
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
|
||||
)
|
||||
}
|
||||
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, {
|
||||
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
|
||||
tile_size: 512,
|
||||
overlap: 64,
|
||||
})
|
||||
assertProjectCurrent()
|
||||
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
|
||||
if (!manifestPath) {
|
||||
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
|
||||
@@ -376,6 +533,7 @@ export function useDetectionWorkflow({
|
||||
tile_manifest_path: manifestPath,
|
||||
model_asset_id: effectiveModelAssetId || null,
|
||||
})
|
||||
assertProjectCurrent()
|
||||
setYoloPreflight(preflight)
|
||||
setYoloPreflightError(null)
|
||||
if (
|
||||
@@ -383,6 +541,7 @@ export function useDetectionWorkflow({
|
||||
!preflight.checks.tile_paths_exist ||
|
||||
!preflight.checks.tile_limit_ok ||
|
||||
!preflight.checks.dependencies_available ||
|
||||
preflight.checks.accelerator_ready !== true ||
|
||||
!preflight.checks.model_file_exists
|
||||
) {
|
||||
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
|
||||
@@ -390,20 +549,25 @@ export function useDetectionWorkflow({
|
||||
|
||||
setDetectionWorkflowStage('detecting')
|
||||
const result = await executeDetection(
|
||||
selectedProjectId,
|
||||
projectId,
|
||||
datasetId,
|
||||
manifestPath,
|
||||
effectiveModelId,
|
||||
effectiveModelAssetId,
|
||||
)
|
||||
assertProjectCurrent()
|
||||
setDetectionWorkflowStage('complete')
|
||||
return result
|
||||
} catch (error) {
|
||||
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
|
||||
setDetectionWorkflowStage('failed')
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
setRunningDetection(false)
|
||||
if (selectedProjectIdRef.current === projectId) {
|
||||
setRunningDetection(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,26 +585,51 @@ export function useDetectionWorkflow({
|
||||
setDetectionQaError('Kies eerst een referentiebron')
|
||||
return null
|
||||
}
|
||||
const projectId = selectedProjectIdRef.current
|
||||
if (!projectId) {
|
||||
setDetectionQaError('Kies eerst een werkruimte')
|
||||
return null
|
||||
}
|
||||
const sequence = detectionQaRequestSequence.current + 1
|
||||
detectionQaRequestSequence.current = sequence
|
||||
setSelectedDetectionRunId(analysisRunId)
|
||||
setDetectionReferenceDatasetId(referenceDatasetId)
|
||||
setDetectionQaError(null)
|
||||
setDetectionQaResult(null)
|
||||
setRunningDetectionQa(true)
|
||||
try {
|
||||
const result = await detectionApi.compareWithReference(analysisRunId, selectedProjectId!, {
|
||||
const result = await detectionApi.compareWithReference(analysisRunId, projectId, {
|
||||
reference_dataset_id: referenceDatasetId,
|
||||
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
|
||||
class_name: useCurrentFilters ? detectionClassFilter || null : null,
|
||||
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
|
||||
})
|
||||
if (
|
||||
detectionQaRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) return null
|
||||
setDetectionQaResult(result)
|
||||
await loadQualityChecks(selectedProjectId)
|
||||
await loadQualityChecks(projectId)
|
||||
if (
|
||||
detectionQaRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) return null
|
||||
return result
|
||||
} catch (error) {
|
||||
setDetectionQaError(formatError(error, 'Detection QA failed'))
|
||||
if (
|
||||
detectionQaRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setDetectionQaError(formatError(error, 'Detection QA failed'))
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
setRunningDetectionQa(false)
|
||||
if (
|
||||
detectionQaRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setRunningDetectionQa(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +641,7 @@ export function useDetectionWorkflow({
|
||||
setDetectionCalibrationError('Kies eerst een werkruimte om te kalibreren')
|
||||
return
|
||||
}
|
||||
const projectId = selectedProjectId
|
||||
const datasetId = selectedDetectionDatasetId
|
||||
if (!datasetId) {
|
||||
setDetectionCalibrationError('Kies eerst een rasterbron om te kalibreren')
|
||||
@@ -461,13 +651,14 @@ export function useDetectionWorkflow({
|
||||
setDetectionCalibrationError('Kies eerst een referentiebron om te kalibreren')
|
||||
return
|
||||
}
|
||||
const referenceDatasetId = detectionReferenceDatasetId
|
||||
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
|
||||
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
|
||||
setDetectionCalibrationError('Kies eerst een geconfigureerd detectiemodel; testgegevens kunnen niet gekalibreerd worden')
|
||||
return
|
||||
}
|
||||
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
|
||||
setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest')
|
||||
setDetectionCalibrationError('Kalibratie met YOLO vereist een beeldtegelmanifest')
|
||||
return
|
||||
}
|
||||
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
|
||||
@@ -476,9 +667,17 @@ export function useDetectionWorkflow({
|
||||
}
|
||||
const thresholds = parseCalibrationThresholds(calibrationThresholdText)
|
||||
if (thresholds.length === 0) {
|
||||
setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1')
|
||||
setDetectionCalibrationError('Geef minstens één geldige drempel tussen 0 en 1 op')
|
||||
return
|
||||
}
|
||||
const sequence = detectionCalibrationSequence.current + 1
|
||||
detectionCalibrationSequence.current = sequence
|
||||
const assertCalibrationCurrent = () => {
|
||||
if (
|
||||
detectionCalibrationSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) throw abortedError()
|
||||
}
|
||||
setDetectionCalibrationError(null)
|
||||
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
|
||||
setRunningDetectionCalibration(true)
|
||||
@@ -492,24 +691,28 @@ export function useDetectionWorkflow({
|
||||
setDetectionCalibrationRows((rows) =>
|
||||
rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })),
|
||||
)
|
||||
const result = await detectionApi.run({
|
||||
project_id: selectedProjectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: selectedDetectionModelId,
|
||||
model_asset_id: selectedModelAssetId || null,
|
||||
confidence_threshold: lowestThreshold,
|
||||
tile_manifest_path: detectionTileManifestPath.trim() || null,
|
||||
parameters_json: { calibration: true, calibration_thresholds: thresholds },
|
||||
})
|
||||
setDetectionWorkflowStage('detecting')
|
||||
const result = await executeDetection(
|
||||
projectId,
|
||||
datasetId,
|
||||
detectionTileManifestPath.trim() || null,
|
||||
selectedDetectionModelId,
|
||||
selectedModelAssetId,
|
||||
lowestThreshold,
|
||||
{ calibration: true, calibration_thresholds: thresholds },
|
||||
)
|
||||
assertCalibrationCurrent()
|
||||
setDetectionWorkflowStage('complete')
|
||||
setSelectedDetectionRunId(result.analysis_run_id)
|
||||
|
||||
const qa = await detectionApi.compareWithReference(result.analysis_run_id, selectedProjectId, {
|
||||
reference_dataset_id: detectionReferenceDatasetId,
|
||||
const qa = await detectionApi.compareWithReference(result.analysis_run_id, projectId, {
|
||||
reference_dataset_id: referenceDatasetId,
|
||||
iou_threshold: qaIouThreshold,
|
||||
class_name: detectionClassFilter || null,
|
||||
min_confidence: null,
|
||||
calibration_thresholds: thresholds,
|
||||
})
|
||||
assertCalibrationCurrent()
|
||||
|
||||
const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point]))
|
||||
setDetectionCalibrationRows((rows) =>
|
||||
@@ -536,17 +739,31 @@ export function useDetectionWorkflow({
|
||||
}),
|
||||
)
|
||||
|
||||
await loadDetectionRuns(selectedProjectId)
|
||||
await loadQualityChecks(selectedProjectId)
|
||||
await loadProjectData(selectedProjectId)
|
||||
await loadDetectionRuns(projectId)
|
||||
assertCalibrationCurrent()
|
||||
await loadQualityChecks(projectId)
|
||||
assertCalibrationCurrent()
|
||||
await loadProjectData(projectId)
|
||||
assertCalibrationCurrent()
|
||||
} catch (error) {
|
||||
const message = formatError(error, 'Calibration failed')
|
||||
setDetectionCalibrationRows((rows) =>
|
||||
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
|
||||
)
|
||||
setDetectionCalibrationError(message)
|
||||
if (
|
||||
!isAbortError(error)
|
||||
&& detectionCalibrationSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
const message = formatError(error, 'Kalibratie mislukt')
|
||||
setDetectionCalibrationRows((rows) =>
|
||||
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
|
||||
)
|
||||
setDetectionCalibrationError(message)
|
||||
}
|
||||
} finally {
|
||||
setRunningDetectionCalibration(false)
|
||||
if (
|
||||
detectionCalibrationSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setRunningDetectionCalibration(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,14 +774,32 @@ export function useDetectionWorkflow({
|
||||
}
|
||||
|
||||
const resetDetectionForProject = () => {
|
||||
detectionExecutionSequence.current += 1
|
||||
detectionRunsRequestSequence.current += 1
|
||||
detectionResultsRequestSequence.current += 1
|
||||
detectionQaRequestSequence.current += 1
|
||||
detectionCalibrationSequence.current += 1
|
||||
activeDetectionControllerRef.current?.abort()
|
||||
activeDetectionControllerRef.current = null
|
||||
setSelectedDetectionDatasetId('')
|
||||
setDetectionRuns([])
|
||||
setSelectedDetectionRunId('')
|
||||
setDetectionItems([])
|
||||
setDetectionTotal(0)
|
||||
setDetectionTruncated(false)
|
||||
setDetectionGeoJson(null)
|
||||
setDetectionRunResult(null)
|
||||
setDetectionJob(null)
|
||||
setDetectionReferenceDatasetId('')
|
||||
setDetectionQaResult(null)
|
||||
setDetectionQaError(null)
|
||||
setRunningDetectionQa(false)
|
||||
setDetectionCalibrationRows([])
|
||||
setDetectionCalibrationError(null)
|
||||
setRunningDetectionCalibration(false)
|
||||
setDetectionRunError(null)
|
||||
setLoadingDetectionResults(false)
|
||||
setRunningDetection(false)
|
||||
setDetectionWorkflowStage('idle')
|
||||
}
|
||||
|
||||
@@ -580,6 +815,7 @@ export function useDetectionWorkflow({
|
||||
detectionTileManifestPath,
|
||||
detectionConfidenceThreshold,
|
||||
runningDetection,
|
||||
detectionJob,
|
||||
detectionRunResult,
|
||||
detectionRunError,
|
||||
detectionRuns,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantQueryResponse } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
models: vi.fn(),
|
||||
query: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/api/assistant', () => ({
|
||||
assistantApi: mocks,
|
||||
}))
|
||||
|
||||
import { useGeoAssistant } from './useGeoAssistant'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function response(answer: string): AssistantQueryResponse {
|
||||
return {
|
||||
answer,
|
||||
model: 'geo-model',
|
||||
scope_label: 'testgebied',
|
||||
context_metrics: [],
|
||||
temporal_series: [],
|
||||
source_dataset_ids: [],
|
||||
warnings: [],
|
||||
generated_at: '2026-08-23T12:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGeoAssistant request scope', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
mocks.status.mockResolvedValue({
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
status: 'ready',
|
||||
base_url: 'http://localhost',
|
||||
default_model: 'geo-model',
|
||||
model_count: 1,
|
||||
limitation_message: '',
|
||||
})
|
||||
mocks.models.mockResolvedValue({
|
||||
items: [{ name: 'geo-model', capabilities: ['chat'] }],
|
||||
total: 1,
|
||||
default_model: 'geo-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores an answer that returns after the active project changed', async () => {
|
||||
const pending = deferred<AssistantQueryResponse>()
|
||||
mocks.query.mockReturnValueOnce(pending.promise)
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useGeoAssistant({
|
||||
selectedProjectId: projectId,
|
||||
selectedAreaId: null,
|
||||
selectionBbox: null,
|
||||
}),
|
||||
{ initialProps: { projectId: 'project-1' } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
|
||||
|
||||
let request!: Promise<boolean>
|
||||
act(() => {
|
||||
request = result.current.ask('Wat staat hier?')
|
||||
})
|
||||
rerender({ projectId: 'project-2' })
|
||||
await act(async () => {
|
||||
pending.resolve(response('antwoord uit project 1'))
|
||||
await request
|
||||
})
|
||||
|
||||
expect(result.current.messages).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('lets only the newest request update a conversation', async () => {
|
||||
const older = deferred<AssistantQueryResponse>()
|
||||
const newer = deferred<AssistantQueryResponse>()
|
||||
mocks.query
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise)
|
||||
const { result } = renderHook(() => useGeoAssistant({
|
||||
selectedProjectId: 'project-1',
|
||||
selectedAreaId: null,
|
||||
selectionBbox: null,
|
||||
}))
|
||||
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
|
||||
|
||||
let olderRequest!: Promise<boolean>
|
||||
let newerRequest!: Promise<boolean>
|
||||
act(() => { olderRequest = result.current.ask('Eerste vraag') })
|
||||
act(() => { newerRequest = result.current.ask('Tweede vraag') })
|
||||
await act(async () => {
|
||||
newer.resolve(response('nieuwste antwoord'))
|
||||
await newerRequest
|
||||
})
|
||||
await act(async () => {
|
||||
older.resolve(response('verouderd antwoord'))
|
||||
await olderRequest
|
||||
})
|
||||
|
||||
const assistantMessages = result.current.messages.filter((message) => message.role === 'assistant')
|
||||
expect(assistantMessages.map((message) => message.content)).toEqual(['nieuwste antwoord'])
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { assistantApi } from '../services/api/assistant'
|
||||
import type {
|
||||
@@ -36,15 +36,61 @@ function readStoredPreference(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function assistantScopeKey(
|
||||
projectId: string | null,
|
||||
areaId: string | null,
|
||||
bbox: VectorSelectionBBox | null,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
projectId,
|
||||
areaId,
|
||||
bbox?.min_x ?? null,
|
||||
bbox?.min_y ?? null,
|
||||
bbox?.max_x ?? null,
|
||||
bbox?.max_y ?? null,
|
||||
bbox?.crs ?? null,
|
||||
])
|
||||
}
|
||||
|
||||
interface AssistantConversationState {
|
||||
scopeKey: string
|
||||
messages: GeoAssistantMessage[]
|
||||
}
|
||||
|
||||
interface AssistantRequestState {
|
||||
scopeKey: string
|
||||
requestId: number
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
|
||||
const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox)
|
||||
const activeScopeRef = useRef(scopeKey)
|
||||
const latestRequestIdRef = useRef(0)
|
||||
if (activeScopeRef.current !== scopeKey) {
|
||||
activeScopeRef.current = scopeKey
|
||||
latestRequestIdRef.current += 1
|
||||
}
|
||||
|
||||
const [status, setStatus] = useState<AssistantStatus | null>(null)
|
||||
const [models, setModels] = useState<AssistantModelRead[]>([])
|
||||
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
|
||||
const [defaultModel, setDefaultModel] = useState('')
|
||||
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
|
||||
const [requestState, setRequestState] = useState<AssistantRequestState>({
|
||||
scopeKey,
|
||||
requestId: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modelError, setModelError] = useState<string | null>(null)
|
||||
|
||||
const messages = conversation.scopeKey === scopeKey ? conversation.messages : []
|
||||
const loading = requestState.scopeKey === scopeKey && requestState.loading
|
||||
const queryError = requestState.scopeKey === scopeKey ? requestState.error : null
|
||||
const error = queryError ?? modelError
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
const available = new Set(models.map((model) => model.name))
|
||||
@@ -62,7 +108,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
|
||||
const loadModels = async () => {
|
||||
setLoadingModels(true)
|
||||
setError(null)
|
||||
setModelError(null)
|
||||
try {
|
||||
const currentStatus = await assistantApi.status()
|
||||
setStatus(currentStatus)
|
||||
@@ -82,22 +128,39 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
setStatus(null)
|
||||
setModels([])
|
||||
setDefaultModel('')
|
||||
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
|
||||
setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void loadModels() }, [])
|
||||
useEffect(() => { setMessages([]); setError(null) }, [selectedProjectId])
|
||||
useEffect(() => {
|
||||
setConversation({ scopeKey, messages: [] })
|
||||
setRequestState({
|
||||
scopeKey,
|
||||
requestId: latestRequestIdRef.current,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}, [scopeKey])
|
||||
|
||||
const ask = async (question: string): Promise<boolean> => {
|
||||
const trimmed = question.trim()
|
||||
if (!selectedProjectId || !trimmed || !selectedModel) return false
|
||||
const requestId = latestRequestIdRef.current + 1
|
||||
latestRequestIdRef.current = requestId
|
||||
const requestScopeKey = scopeKey
|
||||
const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed }
|
||||
setMessages((current) => [...current, userMessage])
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setConversation((current) => ({
|
||||
scopeKey: requestScopeKey,
|
||||
messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage],
|
||||
}))
|
||||
setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null })
|
||||
const isLatestRequest = () => (
|
||||
latestRequestIdRef.current === requestId
|
||||
&& activeScopeRef.current === requestScopeKey
|
||||
)
|
||||
try {
|
||||
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
|
||||
const result = await assistantApi.query(selectedProjectId, {
|
||||
@@ -107,17 +170,40 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
area_id: selectedAreaId,
|
||||
history,
|
||||
})
|
||||
setMessages((current) => [...current, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }])
|
||||
if (!isLatestRequest()) return false
|
||||
setConversation((current) => current.scopeKey === requestScopeKey ? {
|
||||
scopeKey: requestScopeKey,
|
||||
messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }],
|
||||
} : current)
|
||||
return true
|
||||
} catch (requestError) {
|
||||
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
|
||||
if (!isLatestRequest()) return false
|
||||
setRequestState({
|
||||
scopeKey: requestScopeKey,
|
||||
requestId,
|
||||
loading: false,
|
||||
error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'),
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (isLatestRequest()) {
|
||||
setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId
|
||||
? { ...current, loading: false }
|
||||
: current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => { setMessages([]); setError(null) }
|
||||
const clear = () => {
|
||||
latestRequestIdRef.current += 1
|
||||
setConversation({ scopeKey, messages: [] })
|
||||
setRequestState({
|
||||
scopeKey,
|
||||
requestId: latestRequestIdRef.current,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listModels: vi.fn(),
|
||||
runAsync: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
listSegmentations: vi.fn(),
|
||||
getRunGeoJson: vi.fn(),
|
||||
compareWithReference: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/api', () => ({
|
||||
segmentationApi: {
|
||||
listModels: mocks.listModels,
|
||||
runAsync: mocks.runAsync,
|
||||
listRuns: mocks.listRuns,
|
||||
getRun: mocks.getRun,
|
||||
listSegmentations: mocks.listSegmentations,
|
||||
getRunGeoJson: mocks.getRunGeoJson,
|
||||
compareWithReference: mocks.compareWithReference,
|
||||
},
|
||||
}))
|
||||
|
||||
import { useSegmentationWorkflow } from './useSegmentationWorkflow'
|
||||
|
||||
const projectId = 'project-1'
|
||||
const datasetId = 'dataset-1'
|
||||
const jobId = 'job-1'
|
||||
const analysisRunId = 'run-1'
|
||||
|
||||
const completedJob: JobRead = {
|
||||
id: jobId,
|
||||
job_type: 'segmentation.run',
|
||||
status: 'success',
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
parameters_json: {},
|
||||
result_json: { analysis_run_id: analysisRunId, segmentation_count: 2 },
|
||||
}
|
||||
|
||||
const persistedRun: SegmentationRunRead = {
|
||||
id: analysisRunId,
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
job_id: jobId,
|
||||
analysis_type: 'segmentation',
|
||||
status: 'success',
|
||||
model_name: 'yolo-seg-configured',
|
||||
parameters_json: {},
|
||||
result_json: { segmentation_count: 2 },
|
||||
}
|
||||
|
||||
function renderWorkflow(selectedProjectId = projectId) {
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const view = renderHook(() => useSegmentationWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}))
|
||||
return { ...view, loadProjectData }
|
||||
}
|
||||
|
||||
describe('useSegmentationWorkflow GPU execution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listModels.mockResolvedValue({
|
||||
models: [{
|
||||
model_id: 'yolo-seg-configured',
|
||||
display_name: 'YOLO segmentatie',
|
||||
framework: 'ultralytics/pytorch',
|
||||
task_type: 'segmentation',
|
||||
supported_classes: ['building'],
|
||||
configured: true,
|
||||
status: 'configured',
|
||||
limitation_message: '',
|
||||
operator_review_required: true,
|
||||
}],
|
||||
})
|
||||
mocks.runAsync.mockResolvedValue(completedJob)
|
||||
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
|
||||
mocks.getRun.mockResolvedValue(persistedRun)
|
||||
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
|
||||
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
|
||||
})
|
||||
|
||||
it('queues, follows and reconciles a persisted segmentation result', async () => {
|
||||
const { result, loadProjectData } = renderWorkflow()
|
||||
await act(async () => { await result.current.loadSegmentationModels() })
|
||||
act(() => {
|
||||
result.current.setSelectedSegmentationDatasetId(datasetId)
|
||||
result.current.setSegmentationTileManifestPath('/tiles/manifest.json')
|
||||
})
|
||||
|
||||
await act(async () => { await result.current.runSegmentation() })
|
||||
|
||||
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: 'yolo-seg-configured',
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}))
|
||||
expect(mocks.getRun).toHaveBeenCalledWith(analysisRunId, projectId)
|
||||
expect(result.current.segmentationRunResult).toMatchObject({
|
||||
analysis_run_id: analysisRunId,
|
||||
job_id: jobId,
|
||||
segmentation_count: 2,
|
||||
status: 'success',
|
||||
})
|
||||
expect(result.current.segmentationRunError).toBeNull()
|
||||
expect(result.current.segmentationTotal).toBe(0)
|
||||
expect(result.current.segmentationTruncated).toBe(false)
|
||||
expect(loadProjectData).toHaveBeenCalledWith(projectId)
|
||||
})
|
||||
|
||||
it('does not queue a configured model without a tile manifest', async () => {
|
||||
const { result } = renderWorkflow()
|
||||
await act(async () => { await result.current.loadSegmentationModels() })
|
||||
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
|
||||
|
||||
await act(async () => { await result.current.runSegmentation() })
|
||||
|
||||
expect(mocks.runAsync).not.toHaveBeenCalled()
|
||||
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
|
||||
})
|
||||
|
||||
it('ignores a late run list after the active project changes', async () => {
|
||||
let resolveOlder!: (value: { items: SegmentationRunRead[]; total: number }) => void
|
||||
let resolveNewer!: (value: { items: SegmentationRunRead[]; total: number }) => void
|
||||
mocks.listRuns
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedProjectId }) => useSegmentationWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
{ initialProps: { selectedProjectId: 'project-1' } },
|
||||
)
|
||||
|
||||
let olderRequest!: Promise<void>
|
||||
let newerRequest!: Promise<void>
|
||||
act(() => { olderRequest = result.current.loadSegmentationRuns('project-1') })
|
||||
rerender({ selectedProjectId: 'project-2' })
|
||||
act(() => { newerRequest = result.current.loadSegmentationRuns('project-2') })
|
||||
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
|
||||
await act(async () => {
|
||||
resolveNewer({ items: [projectTwoRun], total: 1 })
|
||||
await newerRequest
|
||||
})
|
||||
await act(async () => {
|
||||
resolveOlder({ items: [persistedRun], total: 1 })
|
||||
await olderRequest
|
||||
})
|
||||
|
||||
expect(result.current.segmentationRuns).toEqual([projectTwoRun])
|
||||
expect(result.current.selectedSegmentationRunId).toBe('run-2')
|
||||
})
|
||||
|
||||
it('ignores late polygons from another project and clears an empty selection loader', async () => {
|
||||
let resolveOlderList!: (value: { items: SegmentationRead[]; total: number }) => void
|
||||
let resolveNewerList!: (value: { items: SegmentationRead[]; total: number }) => void
|
||||
let resolveOlderGeo!: (value: GeoJSON.FeatureCollection) => void
|
||||
let resolveNewerGeo!: (value: GeoJSON.FeatureCollection) => void
|
||||
mocks.listSegmentations
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
|
||||
mocks.getRunGeoJson
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeo = resolve }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeo = resolve }))
|
||||
const loadProjectData = vi.fn().mockResolvedValue(undefined)
|
||||
const loadQualityChecks = vi.fn().mockResolvedValue([])
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedProjectId }) => useSegmentationWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
{ initialProps: { selectedProjectId: 'project-1' } },
|
||||
)
|
||||
const oldItem: SegmentationRead = {
|
||||
id: 'segment-1', project_id: 'project-1', analysis_run_id: 'run-1', model_name: 'model', class_name: 'building',
|
||||
}
|
||||
const newItem: SegmentationRead = {
|
||||
id: 'segment-2', project_id: 'project-2', analysis_run_id: 'run-2', model_name: 'model', class_name: 'building',
|
||||
}
|
||||
let olderRequest!: Promise<void>
|
||||
let newerRequest!: Promise<void>
|
||||
act(() => { olderRequest = result.current.loadSegmentationResults('run-1') })
|
||||
rerender({ selectedProjectId: 'project-2' })
|
||||
act(() => { newerRequest = result.current.loadSegmentationResults('run-2') })
|
||||
await act(async () => {
|
||||
resolveNewerList({ items: [newItem], total: 1 })
|
||||
resolveNewerGeo({ type: 'FeatureCollection', features: [] })
|
||||
await newerRequest
|
||||
})
|
||||
await act(async () => {
|
||||
resolveOlderList({ items: [oldItem], total: 1 })
|
||||
resolveOlderGeo({ type: 'FeatureCollection', features: [] })
|
||||
await olderRequest
|
||||
})
|
||||
|
||||
expect(result.current.segmentationItems).toEqual([newItem])
|
||||
await act(async () => { await result.current.loadSegmentationResults('') })
|
||||
expect(result.current.loadingSegmentationResults).toBe(false)
|
||||
expect(result.current.segmentationItems).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { segmentationApi } from '../services/api'
|
||||
import type {
|
||||
DatasetCreateResponse,
|
||||
JobRead,
|
||||
QualityCheckRead,
|
||||
SegmentationModelCapability,
|
||||
SegmentationQaResult,
|
||||
@@ -10,6 +11,12 @@ import type {
|
||||
SegmentationRunResponse,
|
||||
} from '../types'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import {
|
||||
analysisRunIdFromSegmentationJob,
|
||||
completedSegmentationResponse,
|
||||
SegmentationJobError,
|
||||
waitForSegmentationJob,
|
||||
} from '../services/segmentationJob'
|
||||
|
||||
interface SegmentationWorkflowOptions {
|
||||
selectedProjectId: string | null
|
||||
@@ -19,6 +26,16 @@ interface SegmentationWorkflowOptions {
|
||||
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
function abortedError(): Error {
|
||||
const error = new Error('Het volgen van de segmentatietaak is gestopt')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function useSegmentationWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets,
|
||||
@@ -34,11 +51,14 @@ export function useSegmentationWorkflow({
|
||||
const [segmentationTileManifestPath, setSegmentationTileManifestPath] = useState('')
|
||||
const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5)
|
||||
const [runningSegmentation, setRunningSegmentation] = useState(false)
|
||||
const [segmentationJob, setSegmentationJob] = useState<JobRead | null>(null)
|
||||
const [segmentationRunResult, setSegmentationRunResult] = useState<SegmentationRunResponse | null>(null)
|
||||
const [segmentationRunError, setSegmentationRunError] = useState<string | null>(null)
|
||||
const [segmentationRuns, setSegmentationRuns] = useState<SegmentationRunRead[]>([])
|
||||
const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('')
|
||||
const [segmentationItems, setSegmentationItems] = useState<SegmentationRead[]>([])
|
||||
const [segmentationTotal, setSegmentationTotal] = useState(0)
|
||||
const [segmentationTruncated, setSegmentationTruncated] = useState(false)
|
||||
const [segmentationGeoJson, setSegmentationGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
|
||||
const [segmentationClassFilter, setSegmentationClassFilter] = useState('')
|
||||
const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0)
|
||||
@@ -47,6 +67,41 @@ export function useSegmentationWorkflow({
|
||||
const [segmentationQaResult, setSegmentationQaResult] = useState<SegmentationQaResult | null>(null)
|
||||
const [segmentationQaError, setSegmentationQaError] = useState<string | null>(null)
|
||||
const [runningSegmentationQa, setRunningSegmentationQa] = useState(false)
|
||||
const activeSegmentationControllerRef = useRef<AbortController | null>(null)
|
||||
const selectedProjectIdRef = useRef(selectedProjectId)
|
||||
const segmentationExecutionSequence = useRef(0)
|
||||
const segmentationRunsRequestSequence = useRef(0)
|
||||
const segmentationResultsRequestSequence = useRef(0)
|
||||
const segmentationQaRequestSequence = useRef(0)
|
||||
selectedProjectIdRef.current = selectedProjectId
|
||||
|
||||
useEffect(() => {
|
||||
activeSegmentationControllerRef.current?.abort()
|
||||
activeSegmentationControllerRef.current = null
|
||||
segmentationExecutionSequence.current += 1
|
||||
segmentationRunsRequestSequence.current += 1
|
||||
segmentationResultsRequestSequence.current += 1
|
||||
segmentationQaRequestSequence.current += 1
|
||||
setSelectedSegmentationDatasetId('')
|
||||
setSegmentationRuns([])
|
||||
setSelectedSegmentationRunId('')
|
||||
setSegmentationItems([])
|
||||
setSegmentationTotal(0)
|
||||
setSegmentationTruncated(false)
|
||||
setSegmentationGeoJson(null)
|
||||
setSegmentationRunResult(null)
|
||||
setSegmentationRunError(null)
|
||||
setSegmentationJob(null)
|
||||
setRunningSegmentation(false)
|
||||
setLoadingSegmentationResults(false)
|
||||
setSegmentationTileManifestPath('')
|
||||
setSegmentationQaResult(null)
|
||||
setSegmentationQaError(null)
|
||||
setRunningSegmentationQa(false)
|
||||
return () => {
|
||||
activeSegmentationControllerRef.current?.abort()
|
||||
}
|
||||
}, [selectedProjectId])
|
||||
|
||||
const selectedSegmentationModel = useMemo(
|
||||
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
|
||||
@@ -79,32 +134,54 @@ export function useSegmentationWorkflow({
|
||||
}
|
||||
|
||||
const loadSegmentationRuns = async (projectId = selectedProjectId) => {
|
||||
const sequence = segmentationRunsRequestSequence.current + 1
|
||||
segmentationRunsRequestSequence.current = sequence
|
||||
if (!projectId) {
|
||||
setSegmentationRuns([])
|
||||
setSelectedSegmentationRunId('')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await segmentationApi.listRuns({ project_id: projectId })
|
||||
if (
|
||||
segmentationRunsRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) return
|
||||
setSegmentationRuns(response.items)
|
||||
if (!selectedSegmentationRunId && response.items.length > 0) {
|
||||
setSelectedSegmentationRunId(response.items[0].id)
|
||||
}
|
||||
setSelectedSegmentationRunId((current) => (
|
||||
response.items.some((run) => run.id === current) ? current : response.items[0]?.id ?? ''
|
||||
))
|
||||
} catch (error) {
|
||||
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
|
||||
if (
|
||||
segmentationRunsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => {
|
||||
if (!analysisRunId) {
|
||||
const sequence = segmentationResultsRequestSequence.current + 1
|
||||
segmentationResultsRequestSequence.current = sequence
|
||||
const requestProjectId = selectedProjectIdRef.current
|
||||
if (!analysisRunId || !requestProjectId) {
|
||||
setSegmentationItems([])
|
||||
setSegmentationTotal(0)
|
||||
setSegmentationTruncated(false)
|
||||
setSegmentationGeoJson(null)
|
||||
setLoadingSegmentationResults(false)
|
||||
return
|
||||
}
|
||||
setLoadingSegmentationResults(true)
|
||||
setSegmentationRunError(null)
|
||||
setSegmentationItems([])
|
||||
setSegmentationTotal(0)
|
||||
setSegmentationTruncated(false)
|
||||
setSegmentationGeoJson(null)
|
||||
try {
|
||||
const params = {
|
||||
project_id: selectedProjectId ?? '',
|
||||
project_id: requestProjectId,
|
||||
class_name: segmentationClassFilter || null,
|
||||
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
|
||||
}
|
||||
@@ -112,12 +189,33 @@ export function useSegmentationWorkflow({
|
||||
segmentationApi.listSegmentations(analysisRunId, params),
|
||||
segmentationApi.getRunGeoJson(analysisRunId, params),
|
||||
])
|
||||
if (
|
||||
segmentationResultsRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== requestProjectId
|
||||
) return
|
||||
if (segmentationsResponse.items.some((item) => (
|
||||
item.project_id !== requestProjectId || item.analysis_run_id !== analysisRunId
|
||||
))) {
|
||||
throw new Error('De server retourneerde segmentaties uit een andere werkruimte of analyserun')
|
||||
}
|
||||
setSegmentationItems(segmentationsResponse.items)
|
||||
setSegmentationTotal(segmentationsResponse.total)
|
||||
setSegmentationTruncated(Boolean(segmentationsResponse.truncated))
|
||||
setSegmentationGeoJson(geoJsonResponse)
|
||||
} catch (error) {
|
||||
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
|
||||
if (
|
||||
segmentationResultsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === requestProjectId
|
||||
) {
|
||||
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
|
||||
}
|
||||
} finally {
|
||||
setLoadingSegmentationResults(false)
|
||||
if (
|
||||
segmentationResultsRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === requestProjectId
|
||||
) {
|
||||
setLoadingSegmentationResults(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,31 +233,109 @@ export function useSegmentationWorkflow({
|
||||
setSegmentationRunError('Het gekozen segmentatiemodel is niet geconfigureerd')
|
||||
return
|
||||
}
|
||||
if (selectedSegmentationModelId === 'fixture-segmenter') {
|
||||
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
|
||||
return
|
||||
}
|
||||
if (!segmentationTileManifestPath.trim()) {
|
||||
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
|
||||
return
|
||||
}
|
||||
if (
|
||||
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|
||||
|| segmentationJob?.status === 'queued'
|
||||
|| segmentationJob?.status === 'running'
|
||||
) {
|
||||
setSegmentationRunError('Er wordt al een GPU-segmentatietaak verwerkt. Wacht tot die taak klaar is.')
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = selectedProjectId
|
||||
const parameters: Record<string, unknown> = {}
|
||||
const request = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: selectedSegmentationModelId,
|
||||
confidence_threshold: segmentationConfidenceThreshold,
|
||||
tile_manifest_path: segmentationTileManifestPath.trim() || null,
|
||||
parameters_json: parameters,
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const executionSequence = segmentationExecutionSequence.current + 1
|
||||
segmentationExecutionSequence.current = executionSequence
|
||||
activeSegmentationControllerRef.current = controller
|
||||
const assertExecutionCurrent = () => {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| segmentationExecutionSequence.current !== executionSequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) {
|
||||
throw abortedError()
|
||||
}
|
||||
}
|
||||
|
||||
setSegmentationRunError(null)
|
||||
setSegmentationRunResult(null)
|
||||
setRunningSegmentation(true)
|
||||
setSegmentationJob(null)
|
||||
try {
|
||||
const parameters =
|
||||
selectedSegmentationModelId === 'fixture-segmenter'
|
||||
? { fixture_mode: true, fixture_segmentations: [] }
|
||||
: {}
|
||||
const result = await segmentationApi.run({
|
||||
project_id: selectedProjectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: selectedSegmentationModelId,
|
||||
confidence_threshold: segmentationConfidenceThreshold,
|
||||
tile_manifest_path: segmentationTileManifestPath.trim() || null,
|
||||
parameters_json: parameters,
|
||||
const queuedJob = await segmentationApi.runAsync(request)
|
||||
assertExecutionCurrent()
|
||||
setSegmentationJob(queuedJob)
|
||||
const completedJob = await waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: queuedJob,
|
||||
signal: controller.signal,
|
||||
onStatus: (job) => {
|
||||
if (
|
||||
segmentationExecutionSequence.current === executionSequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setSegmentationJob(job)
|
||||
}
|
||||
},
|
||||
})
|
||||
assertExecutionCurrent()
|
||||
const explicitAnalysisRunId = analysisRunIdFromSegmentationJob(completedJob)
|
||||
const run = explicitAnalysisRunId
|
||||
? await segmentationApi.getRun(explicitAnalysisRunId, projectId)
|
||||
: (await segmentationApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
|
||||
.find((candidate) => candidate.job_id === completedJob.id)
|
||||
assertExecutionCurrent()
|
||||
if (!run) {
|
||||
throw new SegmentationJobError(
|
||||
'De GPU-taak is voltooid, maar de bijbehorende bewaarde segmentatierun ontbreekt.',
|
||||
'SEGMENTATION_RUN_RESULT_NOT_FOUND',
|
||||
completedJob.id,
|
||||
)
|
||||
}
|
||||
const result = completedSegmentationResponse(request, completedJob, run)
|
||||
setSegmentationRunError(null)
|
||||
setSegmentationRunResult(result)
|
||||
setSelectedSegmentationRunId(result.analysis_run_id)
|
||||
await loadSegmentationRuns(selectedProjectId)
|
||||
await loadSegmentationRuns(projectId)
|
||||
assertExecutionCurrent()
|
||||
await loadSegmentationResults(result.analysis_run_id)
|
||||
await loadProjectData(selectedProjectId)
|
||||
assertExecutionCurrent()
|
||||
await loadProjectData(projectId)
|
||||
} catch (error) {
|
||||
setSegmentationRunError(formatError(error, 'Segmentation run failed'))
|
||||
if (
|
||||
!isAbortError(error)
|
||||
&& segmentationExecutionSequence.current === executionSequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setSegmentationRunError(formatError(error, 'De segmentatie is mislukt'))
|
||||
}
|
||||
} finally {
|
||||
setRunningSegmentation(false)
|
||||
if (activeSegmentationControllerRef.current === controller) {
|
||||
activeSegmentationControllerRef.current = null
|
||||
}
|
||||
if (
|
||||
segmentationExecutionSequence.current === executionSequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setRunningSegmentation(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,33 +348,71 @@ export function useSegmentationWorkflow({
|
||||
setSegmentationQaError('Kies eerst een referentiebron')
|
||||
return
|
||||
}
|
||||
const projectId = selectedProjectIdRef.current
|
||||
if (!projectId) {
|
||||
setSegmentationQaError('Kies eerst een werkruimte')
|
||||
return
|
||||
}
|
||||
const analysisRunId = selectedSegmentationRunId
|
||||
const referenceDatasetId = segmentationReferenceDatasetId
|
||||
const sequence = segmentationQaRequestSequence.current + 1
|
||||
segmentationQaRequestSequence.current = sequence
|
||||
setSegmentationQaError(null)
|
||||
setSegmentationQaResult(null)
|
||||
setRunningSegmentationQa(true)
|
||||
try {
|
||||
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, selectedProjectId!, {
|
||||
reference_dataset_id: segmentationReferenceDatasetId,
|
||||
const result = await segmentationApi.compareWithReference(analysisRunId, projectId, {
|
||||
reference_dataset_id: referenceDatasetId,
|
||||
iou_threshold: qaIouThreshold,
|
||||
class_name: segmentationClassFilter || null,
|
||||
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
|
||||
})
|
||||
if (
|
||||
segmentationQaRequestSequence.current !== sequence
|
||||
|| selectedProjectIdRef.current !== projectId
|
||||
) return
|
||||
setSegmentationQaResult(result)
|
||||
await loadQualityChecks(selectedProjectId)
|
||||
await loadQualityChecks(projectId)
|
||||
} catch (error) {
|
||||
setSegmentationQaError(formatError(error, 'Segmentation QA failed'))
|
||||
if (
|
||||
segmentationQaRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setSegmentationQaError(formatError(error, 'De segmentatiecontrole is mislukt'))
|
||||
}
|
||||
} finally {
|
||||
setRunningSegmentationQa(false)
|
||||
if (
|
||||
segmentationQaRequestSequence.current === sequence
|
||||
&& selectedProjectIdRef.current === projectId
|
||||
) {
|
||||
setRunningSegmentationQa(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetSegmentationForProject = () => {
|
||||
activeSegmentationControllerRef.current?.abort()
|
||||
activeSegmentationControllerRef.current = null
|
||||
segmentationExecutionSequence.current += 1
|
||||
segmentationRunsRequestSequence.current += 1
|
||||
segmentationResultsRequestSequence.current += 1
|
||||
segmentationQaRequestSequence.current += 1
|
||||
setSelectedSegmentationDatasetId('')
|
||||
setSegmentationRuns([])
|
||||
setSelectedSegmentationRunId('')
|
||||
setSegmentationItems([])
|
||||
setSegmentationTotal(0)
|
||||
setSegmentationTruncated(false)
|
||||
setSegmentationGeoJson(null)
|
||||
setSegmentationRunResult(null)
|
||||
setSegmentationRunError(null)
|
||||
setSegmentationJob(null)
|
||||
setRunningSegmentation(false)
|
||||
setLoadingSegmentationResults(false)
|
||||
setSegmentationTileManifestPath('')
|
||||
setSegmentationQaResult(null)
|
||||
setSegmentationQaError(null)
|
||||
setRunningSegmentationQa(false)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -211,11 +425,14 @@ export function useSegmentationWorkflow({
|
||||
segmentationTileManifestPath,
|
||||
segmentationConfidenceThreshold,
|
||||
runningSegmentation,
|
||||
segmentationJob,
|
||||
segmentationRunResult,
|
||||
segmentationRunError,
|
||||
segmentationRuns,
|
||||
selectedSegmentationRunId,
|
||||
segmentationItems,
|
||||
segmentationTotal,
|
||||
segmentationTruncated,
|
||||
segmentationGeoJson,
|
||||
segmentationClassFilter,
|
||||
segmentationMinConfidenceFilter,
|
||||
|
||||
@@ -69,4 +69,34 @@ describe('useTemporalComparison', () => {
|
||||
preview_limit: 500,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newer comparison when an older request finishes last', async () => {
|
||||
const resolvers: Array<(value: TemporalComparisonResponse) => void> = []
|
||||
mocks.compare.mockImplementation(() => new Promise<TemporalComparisonResponse>((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
}))
|
||||
const older = { earlier_dataset_id: 'older' } as unknown as TemporalComparisonResponse
|
||||
const newer = { earlier_dataset_id: 'newer' } as unknown as TemporalComparisonResponse
|
||||
const { result } = renderHook(() => useTemporalComparison('project-1'))
|
||||
|
||||
let olderRequest: Promise<TemporalComparisonResponse | null>
|
||||
let newerRequest: Promise<TemporalComparisonResponse | null>
|
||||
await act(async () => {
|
||||
olderRequest = result.current.compareTemporalSnapshots('older', 'later', bbox)
|
||||
newerRequest = result.current.compareTemporalSnapshots('newer', 'later', bbox)
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
resolvers[1](newer)
|
||||
await newerRequest!
|
||||
})
|
||||
expect(result.current.temporalComparison).toEqual(newer)
|
||||
|
||||
await act(async () => {
|
||||
resolvers[0](older)
|
||||
await olderRequest!
|
||||
})
|
||||
expect(result.current.temporalComparison).toEqual(newer)
|
||||
expect(result.current.temporalComparisonLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { temporalApi } from '../services/api/temporal'
|
||||
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
|
||||
@@ -7,15 +7,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null)
|
||||
const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false)
|
||||
const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null)
|
||||
const requestSequence = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
requestSequence.current += 1
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(null)
|
||||
setTemporalComparisonLoading(false)
|
||||
}, [selectedProjectId])
|
||||
|
||||
const clearTemporalComparison = () => {
|
||||
requestSequence.current += 1
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(null)
|
||||
setTemporalComparisonLoading(false)
|
||||
}
|
||||
|
||||
const compareTemporalSnapshots = async (
|
||||
@@ -24,6 +29,8 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
bbox: VectorSelectionBBox,
|
||||
areaId?: string,
|
||||
): Promise<TemporalComparisonResponse | null> => {
|
||||
const sequence = requestSequence.current + 1
|
||||
requestSequence.current = sequence
|
||||
if (!selectedProjectId) {
|
||||
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
|
||||
return null
|
||||
@@ -43,14 +50,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
area_id: areaId || null,
|
||||
preview_limit: 500,
|
||||
})
|
||||
setTemporalComparison(result)
|
||||
if (requestSequence.current === sequence) {
|
||||
setTemporalComparison(result)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
|
||||
if (requestSequence.current === sequence) {
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
setTemporalComparisonLoading(false)
|
||||
if (requestSequence.current === sequence) {
|
||||
setTemporalComparisonLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,19 +97,25 @@ describe('useWorkbenchBootstrap', () => {
|
||||
await waitFor(() => expect(systeem.loadCapabilities).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('blijft geladen wanneer de gebruiker terugkeert naar de kaart', async () => {
|
||||
it('herlaadt bezochte werkbladen niet wanneer een ander werkblad opent', async () => {
|
||||
const state = options('project-1', 'ai')
|
||||
const { rerender } = renderHook((props: { werkblad: string }) =>
|
||||
useWorkbenchBootstrap({ ...state, activeWorkspace: props.werkblad }), {
|
||||
initialProps: { werkblad: 'ai' },
|
||||
})
|
||||
await waitFor(() => expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1'))
|
||||
const naEerste = state.loadDetectionRuns.mock.calls.length
|
||||
const detectionRunCalls = state.loadDetectionRuns.mock.calls.length
|
||||
const detectionResultCalls = state.loadDetectionResults.mock.calls.length
|
||||
|
||||
rerender({ werkblad: 'map' })
|
||||
// Een bezocht werkblad blijft bijgewerkt worden; het wordt niet opnieuw
|
||||
// dichtgezet zodra de gebruiker wegklikt.
|
||||
expect(state.loadDetectionRuns.mock.calls.length).toBeGreaterThanOrEqual(naEerste)
|
||||
rerender({ werkblad: 'exports' })
|
||||
await waitFor(() => expect(state.loadExports).toHaveBeenCalledOnce())
|
||||
rerender({ werkblad: 'analysis' })
|
||||
await waitFor(() => expect(state.loadQualityChecks).toHaveBeenCalledOnce())
|
||||
|
||||
expect(state.loadDetectionRuns).toHaveBeenCalledTimes(detectionRunCalls)
|
||||
expect(state.loadDetectionResults).toHaveBeenCalledTimes(detectionResultCalls)
|
||||
expect(state.loadExports).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('meldt een mislukte laadactie in plaats van haar weg te slikken', async () => {
|
||||
|
||||
@@ -74,24 +74,17 @@ export function useWorkbenchBootstrap({
|
||||
return null
|
||||
}
|
||||
|
||||
// Welke werkbladen welke gegevens nodig hebben. Alles werd voorheen bij het
|
||||
// opstarten opgehaald, ook voor werkbladen die de gebruiker nooit opent; dat
|
||||
// waren 27 verzoeken in drie golven voordat de kaart bruikbaar was.
|
||||
const bezocht = useRef(new Set<string>())
|
||||
bezocht.current.add(activeWorkspace)
|
||||
const geopend = (werkblad: string): boolean => bezocht.current.has(werkblad)
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects().catch(meld('werkruimtes'))
|
||||
}, [restrictedMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!geopend('system')) return
|
||||
if (activeWorkspace !== 'system') return
|
||||
loadCapabilities().catch(meld('bronkoppelingen'))
|
||||
}, [restrictedMode, activeWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (!geopend('ai')) return
|
||||
if (activeWorkspace !== 'ai') return
|
||||
loadDetectionModels().catch(meld('detectiemodellen'))
|
||||
loadSegmentationModels().catch(meld('segmentatiemodellen'))
|
||||
}, [restrictedMode, activeWorkspace])
|
||||
@@ -114,28 +107,28 @@ export function useWorkbenchBootstrap({
|
||||
}, [restrictedMode, selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !geopend('analysis')) return
|
||||
if (!selectedProjectId || activeWorkspace !== 'analysis') return
|
||||
loadQualityChecks(selectedProjectId).catch(meld('kwaliteitscontroles'))
|
||||
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !geopend('ai')) return
|
||||
if (!selectedProjectId || activeWorkspace !== 'ai') return
|
||||
loadDetectionRuns(selectedProjectId).catch(meld('detectieruns'))
|
||||
loadSegmentationRuns(selectedProjectId).catch(meld('segmentatieruns'))
|
||||
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !geopend('exports')) return
|
||||
if (!selectedProjectId || activeWorkspace !== 'exports') return
|
||||
loadExports(selectedProjectId).catch(meld('downloads'))
|
||||
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (!geopend('ai')) return
|
||||
if (activeWorkspace !== 'ai') return
|
||||
loadDetectionResults().catch(meld('detectieresultaten'))
|
||||
}, [restrictedMode, activeWorkspace, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!geopend('ai')) return
|
||||
if (activeWorkspace !== 'ai') return
|
||||
loadSegmentationResults().catch(meld('segmentatieresultaten'))
|
||||
}, [restrictedMode, activeWorkspace, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user