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
@@ -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