extract and test the four-step GIS workflow

Extracted for its error paths, not its size. Every step can fail or return
nothing, and each outcome has to leave the operator with a status that says
where the chain stopped — a workflow reporting "afgerond" after a step produced
nothing tells them a result exists when it does not. Inline in the component,
none of that was exercised; it now has fourteen tests covering each stopping
point, both rejection kinds, the reuse path and the preconditions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 19:08:44 +02:00
co-authored by Claude Opus 5
parent 6177eecef5
commit 8a26007281
5 changed files with 355 additions and 74 deletions
@@ -0,0 +1,173 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IDLE_STATUS, useFullGisWorkflow } from './useFullGisWorkflow'
import type { DatasetCreateResponse, VectorSelectionBBox } from '../types'
/**
* The workflow's error paths are what matter. A chain that reports "afgerond"
* after a step returned nothing tells an operator a result exists when it does
* not — the one mistake this panel must not make.
*/
const BBOX: VectorSelectionBBox = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' }
const DATASET = { id: 'derived-1', name: 'derived.geojson' } as DatasetCreateResponse
function setup(overrides: Partial<Parameters<typeof useFullGisWorkflow>[0]> = {}) {
const options = {
selectedDataset: { id: 'source-1' } as DatasetCreateResponse,
latestSelectionDataset: DATASET,
qaReferenceDatasetId: 'reference-1',
resolveBbox: () => BBOX,
resolveAreaId: () => 'area-1',
setSelectionBbox: vi.fn(),
onRunSelectionExtract: vi.fn().mockResolvedValue({ feature_count: 3 }),
onDeriveDataset: vi.fn().mockResolvedValue(DATASET),
onExportSelection: vi.fn().mockResolvedValue({}),
onRunQa: vi.fn().mockResolvedValue({ f1_score: 0.9 }),
...overrides,
}
return { options, view: renderHook(() => useFullGisWorkflow(options)) }
}
describe('the happy path', () => {
it('walks all four steps and reports completion', async () => {
const { options, view } = setup()
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onDeriveDataset).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onExportSelection).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onRunQa).toHaveBeenCalledWith(DATASET)
expect(view.result.current.status).toContain('afgerond')
expect(view.result.current.error).toBeNull()
expect(view.result.current.running).toBe(false)
})
it('starts idle', () => {
const { view } = setup()
expect(view.result.current.status).toBe(IDLE_STATUS)
expect(view.result.current.running).toBe(false)
})
})
describe('a step that produces nothing stops the chain', () => {
it('stops at the selection and does not derive a dataset', async () => {
const { options, view } = setup({ onRunSelectionExtract: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toBe('Stopped at query.')
expect(view.result.current.error).toContain('ruimtelijke selectie')
expect(options.onDeriveDataset).not.toHaveBeenCalled()
expect(options.onExportSelection).not.toHaveBeenCalled()
})
it('stops at the derived dataset and does not export or check quality', async () => {
const { options, view } = setup({ onDeriveDataset: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toBe('Stopped at dataset save.')
expect(options.onExportSelection).not.toHaveBeenCalled()
expect(options.onRunQa).not.toHaveBeenCalled()
})
it('reports an unfinished quality check without claiming completion', async () => {
const { view } = setup({ onRunQa: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toContain('niet afgerond')
})
})
describe('a thrown error', () => {
it('surfaces the message and marks the workflow stopped', async () => {
const { view } = setup({ onDeriveDataset: vi.fn().mockRejectedValue(new Error('PostGIS unavailable')) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('PostGIS unavailable')
expect(view.result.current.status).toBe('De werkstroom is gestopt.')
})
it('always releases the running flag', async () => {
const { view } = setup({ onRunSelectionExtract: vi.fn().mockRejectedValue(new Error('boom')) })
await act(async () => void (await view.result.current.run()))
await waitFor(() => expect(view.result.current.running).toBe(false))
})
it('does not present a non-Error rejection as a real message', async () => {
const { view } = setup({ onExportSelection: vi.fn().mockRejectedValue('a string') })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('Full GIS workflow failed.')
})
})
describe('preconditions', () => {
it('refuses to start without a selection', async () => {
const { options, view } = setup({ resolveBbox: () => null })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('werkgebied')
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
})
it('refuses to start without a map layer', async () => {
const { options, view } = setup({ selectedDataset: null })
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
})
it('reports a quality check that was skipped for lack of a reference', async () => {
const { options, view } = setup({ qaReferenceDatasetId: null })
await act(async () => void (await view.result.current.run()))
expect(options.onRunQa).not.toHaveBeenCalled()
expect(view.result.current.status).toContain('Kies een referentielaag')
})
})
describe('reuse mode', () => {
it('checks the last saved result without redoing the selection', async () => {
const { options, view } = setup()
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
expect(options.onRunQa).toHaveBeenCalledWith(DATASET)
expect(view.result.current.status).toContain('opnieuw gebruikt')
})
it('refuses without a saved selection', async () => {
const { options, view } = setup({ latestSelectionDataset: null })
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('Bewaar eerst')
expect(options.onRunQa).not.toHaveBeenCalled()
})
it('refuses without a reference layer', async () => {
const { options, view } = setup({ qaReferenceDatasetId: null })
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('referentielaag')
expect(options.onRunQa).not.toHaveBeenCalled()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useState } from 'react'
import type { DatasetCreateResponse, VectorSelectionBBox } from '../types'
/**
* The four-step GIS workflow: select, derive, export, check quality.
*
* Extracted for its error paths. Every step can fail or return nothing, and
* each outcome has to leave the operator with a status that says where the
* chain stopped — a workflow that silently reports "done" after a failed step
* is the one mistake this panel must not make. Inline in a 3.300-line
* component none of that was exercised.
*/
export type FullWorkflowMode = 'new' | 'reuse'
export const IDLE_STATUS = 'Klaar om de volledige GIS-werkstroom uit te voeren.'
const STOPPED_STATUS = 'De werkstroom is gestopt.'
const GENERIC_FAILURE = 'Full GIS workflow failed.'
interface FullGisWorkflowOptions {
selectedDataset: DatasetCreateResponse | null | undefined
latestSelectionDataset: DatasetCreateResponse | null | undefined
qaReferenceDatasetId: string | null | undefined
resolveBbox: () => VectorSelectionBBox | null
resolveAreaId: (bbox: VectorSelectionBBox) => string | undefined
setSelectionBbox: (bbox: VectorSelectionBBox) => void
onRunSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onDeriveDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onExportSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onRunQa: (dataset: DatasetCreateResponse) => Promise<unknown>
}
export interface FullGisWorkflow {
running: boolean
status: string
error: string | null
mode: FullWorkflowMode
setMode: (mode: FullWorkflowMode) => void
run: () => Promise<void>
}
export function useFullGisWorkflow(options: FullGisWorkflowOptions): FullGisWorkflow {
const [running, setRunning] = useState(false)
const [status, setStatus] = useState(IDLE_STATUS)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<FullWorkflowMode>('new')
const {
selectedDataset,
latestSelectionDataset,
qaReferenceDatasetId,
resolveBbox,
resolveAreaId,
setSelectionBbox,
onRunSelectionExtract,
onDeriveDataset,
onExportSelection,
onRunQa,
} = options
const runReuse = useCallback(async () => {
if (!latestSelectionDataset) {
setError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.')
return
}
if (!qaReferenceDatasetId) {
setError('Kies eerst een referentielaag voor de kwaliteitscontrole.')
return
}
setRunning(true)
setError(null)
try {
setStatus('Laatste bewaarde resultaatlaag opnieuw controleren...')
const qaResult = await onRunQa(latestSelectionDataset)
setStatus(
qaResult
? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.'
: 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.',
)
} catch (thrown) {
setError(thrown instanceof Error ? thrown.message : GENERIC_FAILURE)
setStatus(STOPPED_STATUS)
} finally {
setRunning(false)
}
}, [latestSelectionDataset, onRunQa, qaReferenceDatasetId])
const runNew = useCallback(async () => {
const bbox = resolveBbox()
if (!selectedDataset || !bbox) {
setError('Kies een kaartlaag en een werkgebied of laagbegrenzing.')
return
}
setRunning(true)
setError(null)
try {
setStatus('1/4 Bewaarde kaartobjecten selecteren...')
setSelectionBbox(bbox)
const areaId = resolveAreaId(bbox)
// Each step reports where the chain stopped. A step that returns nothing
// is a failure, not a reason to continue to the next one.
const selection = await onRunSelectionExtract(bbox, areaId)
if (!selection) {
setError('De ruimtelijke selectie kon niet worden afgerond.')
setStatus('Stopped at query.')
return
}
setStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveDataset(bbox, areaId)
if (!derived) {
setError('De afgeleide resultaatlaag kon niet worden aangemaakt.')
setStatus('Stopped at dataset save.')
return
}
setStatus('3/4 Saving GeoJSON export artifact...')
await onExportSelection(bbox, areaId)
if (!qaReferenceDatasetId) {
setStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.')
return
}
setStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...')
const qaResult = await onRunQa(derived)
setStatus(
qaResult
? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.'
: 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.',
)
} catch (thrown) {
setError(thrown instanceof Error ? thrown.message : GENERIC_FAILURE)
setStatus(STOPPED_STATUS)
} finally {
setRunning(false)
}
}, [
onDeriveDataset,
onExportSelection,
onRunQa,
onRunSelectionExtract,
qaReferenceDatasetId,
resolveAreaId,
resolveBbox,
selectedDataset,
setSelectionBbox,
])
const run = useCallback(
() => (mode === 'reuse' ? runReuse() : runNew()),
[mode, runNew, runReuse],
)
return { running, status, error, mode, setMode, run }
}