Complete operational map result workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:35:50 +02:00
parent 8266929b2e
commit a29c787238
33 changed files with 1121 additions and 107 deletions
@@ -2,12 +2,13 @@ from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.models import Dataset, Export
from app.models import Area, Dataset, Export
from app.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
@@ -91,23 +92,31 @@ def test_vector_selection_geojson_export_persists_handoff_artifact(tmp_path, mon
def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(monkeypatch) -> None:
export_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
expected_bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}
captured: dict = {}
monkeypatch.setattr(
ExportService,
"export_vector_selection_geojson",
lambda *_args, **_kwargs: ExportCreateResponse(
def fake_export(*_args, **kwargs):
captured.update(kwargs)
return ExportCreateResponse(
export_id=export_id,
path="storage/exports/demo-selection.geojson",
status="ready",
export_type="vector_selection_geojson",
metadata_json={"source": "vector_selection", "selection_bbox": expected_bbox},
),
)
)
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_export)
response = TestClient(app).post(
"/api/v1/exports/geojson",
json={"dataset_id": str(dataset_id), "export_kind": "vector_selection", "bbox": expected_bbox, "limit": 250},
json={
"dataset_id": str(dataset_id),
"area_id": str(area_id),
"export_kind": "vector_selection",
"bbox": expected_bbox,
"limit": 250,
},
)
assert response.status_code == 200
@@ -116,6 +125,55 @@ def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(mon
assert payload["data"]["export_id"] == str(export_id)
assert payload["data"]["export_type"] == "vector_selection_geojson"
assert payload["data"]["metadata_json"]["selection_bbox"] == expected_bbox
assert captured["area_id"] == area_id
def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="regional-buildings.geojson",
dataset_type="vector",
source="fixture",
status="ready",
)
area_geometry = object()
area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
captured: dict = {}
selection_payload = {
"selection_bbox": selection_bbox,
"selection_area_id": str(area_id),
"feature_count": 0,
"limit": 250,
"truncated": False,
"geojson": {"type": "FeatureCollection", "features": []},
}
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "selection.geojson"))
monkeypatch.setattr(VectorFeatureService, "can_use_full_area_fast_path", lambda *_args: True)
def fake_select(*_args, **kwargs):
captured.update(kwargs)
return selection_payload
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", fake_select)
response = ExportService.export_vector_selection_geojson(
db,
dataset_id,
selection_bbox,
area_id=area_id,
)
assert captured["selection_geometry"] is area_geometry
assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is True
assert response.metadata_json["selection_area_id"] == str(area_id)
def test_frontend_exposes_map_selection_export_action() -> None:
@@ -127,6 +185,7 @@ def test_frontend_exposes_map_selection_export_action() -> None:
assert "'vector_selection'" in types
assert "bbox?: VectorSelectionBBox" in exports_api
assert "area_id?: string" in exports_api
assert "exportMapSelectionGeoJson" in export_hook
assert "vector_selection" in export_hook
assert "Save area export" in map_workspace
@@ -36,7 +36,10 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
assert "void analyzeSelection(bbox)" in workspace
assert "const areaIdForSelection" in workspace
assert "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "map.on('mousedown'" in geomap
assert "map.on('mousemove'" in geomap
assert "map.on('mouseup'" in geomap
@@ -33,8 +33,10 @@ def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None
assert "/datasets/raster/terrain/select" in api
assert "/datasets/raster/flood-hazard/select" in api
assert "Rasterlaag actief" in app
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)" in workspace
assert "void analyzeSelection(bbox)" in workspace
assert "areaIdForSelection(bbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace
def test_maplibre_supports_multiple_persisted_raster_overlays() -> None:
@@ -36,9 +36,12 @@ def test_completed_analysis_hands_off_to_ai_and_downloads_responsively() -> None
assert "onOpenAssistant: () => void" in workspace
assert "onOpenExports: () => void" in workspace
assert "Stel AI-vraag" in workspace
assert "Open downloads" in workspace
assert "Bewaar in downloads" in workspace
assert "persistActiveResultAndOpenDownloads" in workspace
assert "onPersistMapResult(payload)" in workspace
assert "onOpenAssistant={() => setActiveWorkspace('assistant')}" in app
assert "onOpenExports={() => setActiveWorkspace('exports')}" in app
assert "onPersistMapResult={persistMapResult}" in app
assert 'className="workspace-persistent-map"' in app
assert "hidden={activeWorkspace !== 'map'}" in app
assert "{activeWorkspace === 'map' ? (" not in app
@@ -0,0 +1,338 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.main import app
from app.models import Dataset, Export
from app.schemas.export import ExportCreateResponse, MapResultExportRequest
from app.schemas.project import ProjectRead
from app.services.export_service import ExportService
from app.services.project_service import ProjectService
from app.services.storage_service import StorageService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
class FakeSession:
def __init__(self, rows=None):
self.rows = rows or {}
self.added = []
def get(self, model, row_id):
return self.rows.get((model, row_id))
def add(self, row):
self.added.append(row)
def commit(self):
return None
def refresh(self, row):
return row
def bbox_payload() -> dict:
return {
"min_x": 5.10,
"min_y": 51.17,
"max_x": 5.11,
"max_y": 51.18,
"crs": "EPSG:4326",
}
def test_map_result_export_request_requires_a_complete_target() -> None:
with pytest.raises(ValidationError):
MapResultExportRequest(project_id=uuid4(), mode="current", bbox=bbox_payload())
with pytest.raises(ValidationError):
MapResultExportRequest(project_id=uuid4(), mode="evolution", bbox=bbox_payload())
with pytest.raises(ValidationError):
MapResultExportRequest(
project_id=uuid4(),
mode="current",
dataset_id=uuid4(),
bbox=bbox_payload(),
partitioned=True,
)
def test_current_vector_map_result_uses_authoritative_selection_export(monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="buildings.geojson",
dataset_type="vector",
source="fixture",
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
expected = ExportCreateResponse(
export_id=uuid4(),
path="storage/exports/buildings-selection.geojson",
status="ready",
export_type="vector_selection_geojson",
)
captured: dict = {}
def fake_vector_export(*_args, **kwargs):
captured.update(kwargs)
return expected
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_vector_export)
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="current",
dataset_id=dataset_id,
area_id=area_id,
bbox=bbox_payload(),
theme_id="buildings",
),
)
assert response is expected
assert captured["area_id"] == area_id
assert captured["limit"] == 1000
def test_raster_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="space-occupation.tif",
dataset_type="raster",
source="official",
source_name="department_omgeving_thematic_raster",
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
export_path = tmp_path / "space-occupation-analysis.json"
captured: dict = {}
def fake_analyze(_db, captured_project_id, captured_dataset_id, payload):
captured.update(
project_id=captured_project_id,
dataset_id=captured_dataset_id,
area_id=payload.area_id,
)
return {
"selection_bbox": bbox_payload(),
"summary": {"metric_label": "Ruimtebeslag", "metric_value": 12.5, "metric_unit": "ha"},
}
monkeypatch.setattr(ThematicRasterAnalysisService, "analyze", fake_analyze)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="current",
dataset_id=dataset_id,
bbox=bbox_payload(),
theme_id="space_occupation",
),
)
persisted = [row for row in db.added if isinstance(row, Export)]
assert response.export_type == "map_analysis_json"
assert len(persisted) == 1
assert persisted[0].metadata_json["server_recomputed"] is True
assert persisted[0].metadata_json["theme_id"] == "space_occupation"
assert captured == {"project_id": project_id, "dataset_id": dataset_id, "area_id": None}
assert json.loads(export_path.read_text(encoding="utf-8"))["result"]["summary"]["metric_value"] == 12.5
def test_evolution_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
project_id = uuid4()
earlier_id = uuid4()
later_id = uuid4()
db = FakeSession()
export_path = tmp_path / "forest-evolution.json"
captured: dict = {}
class Comparison:
def model_dump(self, *, mode):
assert mode == "json"
return {"temporal_series_key": "forest", "metric": {"absolute_change": -2.0}}
def fake_compare(_db, *, project_id, payload):
captured.update(project_id=project_id, payload=payload)
return Comparison()
monkeypatch.setattr(TemporalAnalysisService, "compare", fake_compare)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="evolution",
earlier_dataset_id=earlier_id,
later_dataset_id=later_id,
bbox=bbox_payload(),
theme_id="forest",
),
)
assert response.export_type == "map_evolution_json"
assert captured["project_id"] == project_id
assert captured["payload"].earlier_dataset_id == earlier_id
assert json.loads(export_path.read_text(encoding="utf-8"))["metric"]["absolute_change"] == -2.0
def test_map_result_export_endpoint_uses_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
export_id = uuid4()
monkeypatch.setattr(
ExportService,
"export_map_result",
lambda *_args: ExportCreateResponse(
export_id=export_id,
path="storage/exports/map-analysis.json",
status="ready",
export_type="map_analysis_json",
),
)
response = TestClient(app).post(
"/api/v1/exports/map-result",
json={
"project_id": str(project_id),
"mode": "current",
"dataset_id": str(dataset_id),
"bbox": bbox_payload(),
"theme_id": "space_occupation",
},
)
assert response.status_code == 200
assert response.json() == {
"data": {
"export_id": str(export_id),
"path": "storage/exports/map-analysis.json",
"status": "ready",
"export_type": "map_analysis_json",
"metadata_json": None,
}
}
def test_frontend_persists_map_result_before_opening_downloads() -> None:
root = Path(__file__).resolve().parents[2]
workspace = (root / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
hook = (root / "frontend/src/hooks/useExportWorkflow.ts").read_text(encoding="utf-8")
api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8")
assert "persistActiveResultAndOpenDownloads" in workspace
assert "onPersistMapResult(payload)" in workspace
assert "Bewaar in downloads" in workspace
assert "persistMapResult" in hook
assert "exportsApi.exportMapResult(payload)" in hook
assert "/api/v1/exports/map-result" in api
def test_project_list_supports_exact_canonical_workspace_lookup(monkeypatch) -> None:
project_id = uuid4()
captured: dict = {}
def fake_list(_db, *, limit, offset, name):
captured.update(limit=limit, offset=offset, name=name)
return [
ProjectRead(
id=project_id,
name="Kempen Regional Workbench",
region="Kempen",
status="active",
)
], 1
monkeypatch.setattr(ProjectService, "list_projects", fake_list)
response = TestClient(app).get(
"/api/v1/projects",
params={"name": "Kempen Regional Workbench", "limit": 1},
)
assert response.status_code == 200
assert response.json()["data"]["items"][0]["id"] == str(project_id)
assert captured == {
"limit": 1,
"offset": 0,
"name": "Kempen Regional Workbench",
}
def test_frontend_fetches_canonical_workspace_outside_default_project_page() -> None:
root = Path(__file__).resolve().parents[2]
workflow = (root / "frontend/src/hooks/useProjectWorkspace.ts").read_text(encoding="utf-8")
api = (root / "frontend/src/services/api/projects.ts").read_text(encoding="utf-8")
assert "projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 })" in workflow
assert "[...canonicalResponse.items, ...response.items]" in workflow
assert "new URLSearchParams()" in api
def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
assert "dataset: queries[index]?.dataset.name" in hook
assert "reason: formatError(item.reason" in hook
assert "failure.dataset}: ${failure.reason}" in hook
def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
root = Path(__file__).resolve().parents[2]
app = (root / "frontend/src/App.tsx").read_text(encoding="utf-8")
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "<main ref={workbenchMainRef}" in app
def test_quality_scores_have_plain_language_interpretation() -> None:
root = Path(__file__).resolve().parents[2]
quality = (root / "frontend/src/components/quality/QualityResultsPanel.tsx").read_text(encoding="utf-8")
detection = (root / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8")
assert "Laatste score (0-1)" in quality
assert "Bruikbaar na controle" in quality
assert "Verkennend, controle vereist" in quality
assert "detectionQualityInterpretation" in detection
assert "Verkennend resultaat; beoordeel fouten" in detection
def test_map_and_detection_workspaces_avoid_page_length_driven_layouts() -> None:
root = Path(__file__).resolve().parents[2]
styles = (root / "frontend/src/styles/app.css").read_text(encoding="utf-8")
premium = (root / "frontend/src/styles/premium.css").read_text(encoding="utf-8")
assert "height: clamp(34rem, calc(100dvh - 10rem), 58rem);" in styles
assert ".geo-theme-list" in styles and "overflow-y: auto;" in styles
assert "@media (max-width: 1240px)" in styles
assert ".workspace-grid-ai {\n grid-template-columns: minmax(0, 1fr);" in premium
assert "max-height: none;" in premium
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2]
exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")
assert "Gebiedsanalyse (JSON)" in exports
assert "Historische vergelijking (JSON)" in exports
assert "Kaartselectie (GeoJSON)" in exports
assert "Klaar om te delen" in exports
assert "Downloads vernieuwen" in exports
assert "JSON bekijken" in exports
@@ -35,10 +35,10 @@ def test_app_entrypoint_has_clean_encoding_and_react_imports() -> None:
app = app_path.read_text(encoding="utf-8")
assert not app_bytes.startswith(b"\xef\xbb\xbf")
assert "import { useEffect, useMemo, useState } from 'react'" in app
assert "import { useEffect, useMemo, useRef, useState } from 'react'" in app
assert "FormEvent" not in app
assert app.count("useEffect(") == 1
assert "window.scrollTo({ top: 0, left: 0 })" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')" in app
@@ -38,12 +38,12 @@ def test_export_center_uses_artifact_actions_and_cards() -> None:
assert "exportMatchesSearch" in export_center
assert "filteredExports = useMemo" in export_center
assert "visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)" in export_center
assert "No exports match the current filters." in export_center
assert "Reset view" in export_center
assert "Show all exports" in export_center
assert "Geen downloads passen bij deze filters." in export_center
assert "Filters wissen" in export_center
assert "Toon alle downloads" in export_center
assert "onExportDataset" in export_center
assert "onExportDetectionRun" in export_center
assert "onExportSegmentationRun" in export_center
assert "download-only HTML artifact" in export_center
assert "HTML alleen downloaden" in export_center
assert 'className="export-preview-panel"' in export_preview
assert "Nog geen bestand gekozen." in export_preview
@@ -35,7 +35,7 @@ def test_shell_preserves_workspace_width_on_standard_desktop_viewports() -> None
assert "@media (max-width: 1360px)" in css
assert ".workbench-inspector {\n grid-column: 1 / -1;" in css
assert "height: auto;" in css
assert "window.scrollTo({ top: 0, left: 0 })" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "}, [activeWorkspace])" in app
@@ -16,7 +16,7 @@ def test_quality_results_panel_exposes_selected_check_drilldown() -> None:
assert "Te controleren laag" in panel
assert "Referentielaag" in panel
assert "Analyserun" in panel
assert "Job" in panel
assert "Taak" in panel
assert "Afgerond" in panel
assert "Laatste controle bekijken" in panel
assert "Controle bekijken" in panel