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
+5 -1
View File
@@ -2,7 +2,7 @@
FastAPI backend for GeoIntel Kempen Foundation Sprints.
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. Drawn/manual selections send only their bbox; `area_id` is reserved for the explicit full-work-area operation. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
## Scope implemented
- Project CRUD
@@ -188,12 +188,16 @@ bash scripts/live_migration_smoke.sh
- Added project metadata JSON export for project, dataset and QA/QC summary state.
- Added export read/list/content endpoints:
- `POST /api/v1/exports/geojson`
- `POST /api/v1/exports/map-result`
- `POST /api/v1/exports/metadata`
- `GET /api/v1/exports/projects/{project_id}/exports`
- `GET /api/v1/exports/{export_id}`
- `GET /api/v1/exports/{export_id}/content`
- Exported detection and segmentation GeoJSON is generated from persisted first-class geometry rows.
- No new migrations, product lines, live providers or AI dependencies are introduced by this export pass.
- Map-result exports recompute current governed vector/raster selections or
historical comparisons on the backend before persisting the artifact. Client
metrics are never accepted as authoritative export content.
- Old offline demo export artifacts can be inspected with `python scripts/cleanup_demo_artifacts.py`
and removed only with an explicit `--apply`. The script keeps the newest exports
per demo project, refuses to delete files outside `STORAGE_ROOT`, and blocks
+7 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.export import GeoJsonExportRequest, MetadataExportRequest, ReportExportRequest
from app.schemas.export import GeoJsonExportRequest, MapResultExportRequest, MetadataExportRequest, ReportExportRequest
from app.services.export_service import ExportService
from app.utils.response import envelope
@@ -22,6 +22,7 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
db,
payload.dataset_id,
payload.bbox.model_dump(),
area_id=payload.area_id,
limit=payload.limit,
name=payload.name,
).model_dump(mode="json")
@@ -49,6 +50,11 @@ def export_project_report(payload: ReportExportRequest, db: Session = Depends(ge
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=dict)
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@router.get("/projects/{project_id}/exports", response_model=dict)
def list_project_exports(
project_id: UUID,
+2 -1
View File
@@ -17,9 +17,10 @@ router = APIRouter(prefix="/projects", tags=["projects"])
def list_projects(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
db: Session = Depends(get_db),
):
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset)
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset, name=name)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
+28
View File
@@ -10,11 +10,13 @@ from app.schemas.operations import VectorSelectionBBox
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
MapResultMode = Literal["current", "evolution"]
class GeoJsonExportRequest(BaseModel):
dataset_id: UUID | None = None
analysis_run_id: UUID | None = None
area_id: UUID | None = None
export_kind: ExportKind = "dataset"
name: str | None = None
bbox: VectorSelectionBBox | None = None
@@ -44,6 +46,32 @@ class ReportExportRequest(BaseModel):
name: str | None = None
class MapResultExportRequest(BaseModel):
project_id: UUID
mode: MapResultMode
bbox: VectorSelectionBBox
dataset_id: UUID | None = None
earlier_dataset_id: UUID | None = None
later_dataset_id: UUID | None = None
area_id: UUID | None = None
partitioned: bool = False
product_key: str | None = None
theme_id: str | None = None
name: str | None = None
@model_validator(mode="after")
def validate_map_target(self) -> "MapResultExportRequest":
if self.mode == "current" and self.dataset_id is None:
raise ValueError("dataset_id is required for current map-result exports")
if self.mode == "evolution" and (
self.earlier_dataset_id is None or self.later_dataset_id is None
):
raise ValueError("earlier_dataset_id and later_dataset_id are required for evolution exports")
if self.partitioned and not self.product_key:
raise ValueError("product_key is required for partitioned raster exports")
return self
class ExportRead(BaseModel):
id: UUID
project_id: UUID
+203 -2
View File
@@ -11,20 +11,199 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest
from app.schemas.export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
MapResultExportRequest,
)
from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest
from app.schemas.temporal import TemporalComparisonRequest
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.segmentation_service import SegmentationService
from app.services.storage_service import StorageService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.terrain_analysis_service import TerrainAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def export_map_result(
db: Session,
payload: MapResultExportRequest,
) -> ExportCreateResponse:
if payload.mode == "evolution":
comparison = TemporalAnalysisService.compare(
db,
project_id=payload.project_id,
payload=TemporalComparisonRequest(
earlier_dataset_id=payload.earlier_dataset_id,
later_dataset_id=payload.later_dataset_id,
bbox=payload.bbox,
area_id=payload.area_id,
),
)
content = comparison.model_dump(mode="json")
target_id = str(payload.later_dataset_id)
filename = ExportService._filename(
payload.name,
f"{target_id}-evolution.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
target_id,
filename,
)
metadata = {
"source": "map_evolution",
"project_id": str(payload.project_id),
"earlier_dataset_id": str(payload.earlier_dataset_id),
"later_dataset_id": str(payload.later_dataset_id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_evolution_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
dataset = db.get(Dataset, payload.dataset_id)
if not dataset or dataset.project_id != payload.project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
return ExportService.export_vector_selection_geojson(
db,
dataset.id,
payload.bbox.model_dump(mode="json"),
area_id=payload.area_id,
name=payload.name,
limit=1000,
)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Map-result export requires a vector or governed raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
if dataset.source_name == "digitaal_vlaanderen_dhmv":
result = (
TerrainAnalysisService.analyze_partitions(
db,
payload.project_id,
TerrainPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "dtm_1m",
),
)
if payload.partitioned
else TerrainAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "vmm_flood_hazard":
result = (
FloodHazardAnalysisService.analyze_partitions(
db,
payload.project_id,
FloodHazardPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "pluviaal_current_t100",
),
)
if payload.partitioned
else FloodHazardAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "department_omgeving_thematic_raster":
result = ThematicRasterAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
else:
raise AppError(
code="MAP_RESULT_EXPORT_UNSUPPORTED",
message="This raster source does not expose a governed map-result export",
details={"source_name": dataset.source_name},
status_code=400,
)
content = {
"mode": "current",
"theme_id": payload.theme_id,
"dataset": {
"id": str(dataset.id),
"name": dataset.name,
"source_name": dataset.source_name,
},
"result": result,
}
filename = ExportService._filename(
payload.name,
f"{dataset.id}-map-analysis.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
str(dataset.id),
filename,
)
metadata = {
"source": "map_analysis",
"project_id": str(payload.project_id),
"dataset_id": str(dataset.id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"partitioned": payload.partitioned,
"product_key": payload.product_key,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_analysis_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
@staticmethod
def export_vector_selection_geojson(
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
area_id: uuid.UUID | None = None,
limit: int = 250,
name: str | None = None,
) -> ExportCreateResponse:
@@ -39,7 +218,28 @@ class ExportService:
status_code=400,
)
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
selection_kwargs: dict[str, Any] = {
"dataset_id": dataset_id,
"bbox": bbox,
"limit": limit,
}
if area_id is not None:
area = db.get(Area, area_id)
if not area or area.project_id != dataset.project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id)
preclipped_partition_filter = (
None
if full_dataset_area
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
)
selection_kwargs.update(
selection_geometry=area.geometry,
selection_area_id=area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
)
selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
metadata = {
@@ -48,6 +248,7 @@ class ExportService:
"dataset_id": str(dataset.id),
"dataset_type": dataset.dataset_type,
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"feature_count": selection["feature_count"],
"limit": selection["limit"],
"truncated": selection["truncated"],
+10 -2
View File
@@ -11,8 +11,16 @@ from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
class ProjectService:
@staticmethod
def list_projects(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted").order_by(Project.created_at.desc())
def list_projects(
db: Session,
limit: int = 50,
offset: int = 0,
name: str | None = None,
) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted")
if name:
query = query.filter(Project.name == name.strip())
query = query.order_by(Project.created_at.desc())
total = query.count()
items = query.offset(offset).limit(limit).all()
return [ProjectRead.model_validate(item) for item in items], total
@@ -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