diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index bcc9ed91..2fa9a7d7 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -32,6 +32,7 @@ MAP_WORKSPACE_SOURCES = ( "components/map/mapWorkspaceUtils.ts", "hooks/useMapImageOverlays.ts", "hooks/useMapRectangleSelection.ts", + "hooks/useFullGisWorkflow.ts", "components/map/MapExplorerView.tsx", "components/map/MapAdvancedWorkbench.tsx", ) diff --git a/backend/tests/test_sprint116_operational_gis_map_workflow.py b/backend/tests/test_sprint116_operational_gis_map_workflow.py index 4bcd8c00..ac86e118 100644 --- a/backend/tests/test_sprint116_operational_gis_map_workflow.py +++ b/backend/tests/test_sprint116_operational_gis_map_workflow.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.frontend_contract import read_map_workspace REPO_ROOT = Path(__file__).resolve().parents[2] @@ -19,7 +20,7 @@ def test_map_uses_road_basemap_with_attribution_and_env_override() -> None: def test_map_workspace_can_select_persisted_database_layer_and_run_query() -> None: - map_workspace = (REPO_ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") + map_workspace = read_map_workspace() app_shell = (REPO_ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8") styles = (REPO_ROOT / "frontend/src/styles/app.css").read_text(encoding="utf-8") diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index b1db9cac..9fd7f920 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -7,6 +7,7 @@ import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays' import { useMapRectangleSelection } from '../../hooks/useMapRectangleSelection' +import { useFullGisWorkflow } from '../../hooks/useFullGisWorkflow' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { TemporalTrendChart } from './TemporalTrendChart' import { MunicipalitySearch } from './MunicipalitySearch' @@ -324,10 +325,6 @@ export function MapWorkspace({ bboxInput, setBboxInput, } = rectangle - const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false) - const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.') - const [fullWorkflowError, setFullWorkflowError] = useState(null) - const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new') const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState(null) const mapAnalysisRequestSequence = useRef(0) const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) @@ -1463,75 +1460,26 @@ export function MapWorkspace({ void analyzeSelection(bbox, areaIdForSelection(bbox)) } - const runFullGisWorkflow = async () => { - const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox - if (fullWorkflowMode === 'reuse') { - if (!latestSelectionDataset) { - setFullWorkflowError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.') - return - } - if (!selectedMapQaReferenceDatasetId) { - setFullWorkflowError('Kies eerst een referentielaag voor de kwaliteitscontrole.') - return - } - setFullWorkflowRunning(true) - setFullWorkflowError(null) - try { - setFullWorkflowStatus('Laatste bewaarde resultaatlaag opnieuw controleren...') - const qaResult = await onRunMapSelectionQa(latestSelectionDataset) - setFullWorkflowStatus(qaResult ? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.' : 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.') - } catch (error) { - setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') - setFullWorkflowStatus('De werkstroom is gestopt.') - } finally { - setFullWorkflowRunning(false) - } - return - } - - if (!selectedMapDataset || !bbox) { - setFullWorkflowError('Kies een kaartlaag en een werkgebied of laagbegrenzing.') - return - } - - setFullWorkflowRunning(true) - setFullWorkflowError(null) - try { - setFullWorkflowStatus('1/4 Bewaarde kaartobjecten selecteren...') - setSelectionBbox(bbox) - const selectionAreaId = areaIdForSelection(bbox) - const selection = await onRunMapSelectionExtract(bbox, selectionAreaId) - if (!selection) { - setFullWorkflowError('De ruimtelijke selectie kon niet worden afgerond.') - setFullWorkflowStatus('Stopped at query.') - return - } - - setFullWorkflowStatus('2/4 Saving derived result dataset...') - const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId) - if (!derived) { - setFullWorkflowError('De afgeleide resultaatlaag kon niet worden aangemaakt.') - setFullWorkflowStatus('Stopped at dataset save.') - return - } - - setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...') - await onExportMapSelection(bbox, selectionAreaId) - - if (selectedMapQaReferenceDatasetId) { - setFullWorkflowStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...') - const qaResult = await onRunMapSelectionQa(derived) - setFullWorkflowStatus(qaResult ? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.' : 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.') - } else { - setFullWorkflowStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.') - } - } catch (error) { - setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') - setFullWorkflowStatus('De werkstroom is gestopt.') - } finally { - setFullWorkflowRunning(false) - } - } + const fullWorkflow = useFullGisWorkflow({ + selectedDataset: selectedMapDataset, + latestSelectionDataset, + qaReferenceDatasetId: selectedMapQaReferenceDatasetId, + resolveBbox: () => currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox, + resolveAreaId: areaIdForSelection, + setSelectionBbox, + onRunSelectionExtract: onRunMapSelectionExtract, + onDeriveDataset: onDeriveMapSelectionDataset, + onExportSelection: onExportMapSelection, + onRunQa: onRunMapSelectionQa, + }) + const { + running: fullWorkflowRunning, + status: fullWorkflowStatus, + error: fullWorkflowError, + mode: fullWorkflowMode, + setMode: setFullWorkflowMode, + run: runFullGisWorkflow, + } = fullWorkflow if (!advancedMode) { return ( diff --git a/frontend/src/hooks/useFullGisWorkflow.test.tsx b/frontend/src/hooks/useFullGisWorkflow.test.tsx new file mode 100644 index 00000000..fe2f2641 --- /dev/null +++ b/frontend/src/hooks/useFullGisWorkflow.test.tsx @@ -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[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() + }) +}) diff --git a/frontend/src/hooks/useFullGisWorkflow.ts b/frontend/src/hooks/useFullGisWorkflow.ts new file mode 100644 index 00000000..53d45578 --- /dev/null +++ b/frontend/src/hooks/useFullGisWorkflow.ts @@ -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 + onDeriveDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onExportSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onRunQa: (dataset: DatasetCreateResponse) => Promise +} + +export interface FullGisWorkflow { + running: boolean + status: string + error: string | null + mode: FullWorkflowMode + setMode: (mode: FullWorkflowMode) => void + run: () => Promise +} + +export function useFullGisWorkflow(options: FullGisWorkflowOptions): FullGisWorkflow { + const [running, setRunning] = useState(false) + const [status, setStatus] = useState(IDLE_STATUS) + const [error, setError] = useState(null) + const [mode, setMode] = useState('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 } +}