407 lines
14 KiB
Python
407 lines
14 KiB
Python
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_partitioned_vector_map_result_uses_governed_partition_export(monkeypatch) -> None:
|
|
project_id = uuid4()
|
|
dataset_id = uuid4()
|
|
dataset = Dataset(
|
|
id=dataset_id,
|
|
project_id=project_id,
|
|
name="vha-municipality.geojson",
|
|
dataset_type="vector",
|
|
source="VHA",
|
|
source_name="vmm_vha_bathymetry_profiles",
|
|
status="ready",
|
|
)
|
|
db = FakeSession({(Dataset, dataset_id): dataset})
|
|
expected = ExportCreateResponse(
|
|
export_id=uuid4(),
|
|
path="storage/exports/bathymetry-profile-selection.geojson",
|
|
status="ready",
|
|
export_type="partitioned_vector_selection_geojson",
|
|
)
|
|
captured: dict = {}
|
|
|
|
def fake_partition_export(*args, **kwargs):
|
|
captured["dataset"] = args[1]
|
|
captured.update(kwargs)
|
|
return expected
|
|
|
|
monkeypatch.setattr(
|
|
ExportService,
|
|
"export_partitioned_vector_selection_geojson",
|
|
fake_partition_export,
|
|
)
|
|
response = ExportService.export_map_result(
|
|
db,
|
|
MapResultExportRequest(
|
|
project_id=project_id,
|
|
mode="current",
|
|
dataset_id=dataset_id,
|
|
bbox=bbox_payload(),
|
|
partitioned=True,
|
|
partition_scope_key="flanders",
|
|
theme_id="bathymetry",
|
|
),
|
|
)
|
|
|
|
assert response is expected
|
|
assert captured["dataset"] is dataset
|
|
assert captured["partition_scope_key"] == "flanders"
|
|
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, project_status):
|
|
captured.update(limit=limit, offset=offset, name=name, project_status=project_status)
|
|
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",
|
|
"project_status": "active",
|
|
}
|
|
|
|
|
|
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 "queries[index]?.dataset?.name" in hook
|
|
assert "queries[index]?.acquisition?.displayName" 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" in app
|
|
assert "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: 1500px)" in styles
|
|
assert ".workspace-grid-ai {\n grid-template-columns: minmax(0, 1fr);" in premium
|
|
assert "max-height: none;" in premium
|
|
|
|
|
|
def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
|
|
root = Path(__file__).resolve().parents[2]
|
|
app_source = (root / "frontend/src/App.tsx").read_text(encoding="utf-8")
|
|
capability_source = (root / "frontend/src/lib/datasetCapabilities.ts").read_text(encoding="utf-8")
|
|
|
|
assert "department_omgeving_thematic_raster" in capability_source
|
|
assert "digitaal_vlaanderen_dhmv" in capability_source
|
|
assert "vmm_flood_hazard" in capability_source
|
|
assert "dataset.dataset_type !== 'raster' || dataset.status !== 'ready'" in capability_source
|
|
assert "const detectionRasterDatasets = useMemo(" in app_source
|
|
assert "rasterDatasets: detectionRasterDatasets" in app_source
|
|
assert "rasterDatasets={detectionRasterDatasets}" in app_source
|
|
assert "!isDetectionImageryDataset(selectedDataset)" in app_source
|
|
|
|
|
|
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
|