MapWorkspace.tsx opened with ~590 lines of theme catalogue, dataset matching and label formatting above a 3.200-line component. None of it is React, all of it is independently testable, and both render paths read from it, so it belongs beside the pure helpers that already live in mapWorkspaceUtils. The contract tests that read MapWorkspace.tsx would have gone red for a move that changes no behaviour at all — 24 of them. That is the brittleness the frontend_contract helper exists to remove, so it gains read_map_workspace(): the workspace is one feature spread over several modules, and a contract belongs to the feature rather than to whichever file currently holds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
486 lines
17 KiB
Python
486 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import json
|
|
from pathlib import Path
|
|
import ssl
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from urllib.error import URLError
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
from geoalchemy2.shape import from_shape
|
|
import pytest
|
|
from shapely.geometry import MultiPolygon, Polygon
|
|
|
|
from app.core.config import Settings
|
|
from app.core.errors import AppError
|
|
from app.db.session import get_db
|
|
from app.main import app
|
|
from app.models import Area, Dataset, DatasetVersion, Project
|
|
from app.schemas.bathymetry import BathymetryPartitionFinalizeRequest
|
|
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
|
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPTS = ROOT / "scripts"
|
|
if str(SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import provision_flanders_geographic_scope as flanders_scope # noqa: E402
|
|
from tests.frontend_contract import read_map_workspace
|
|
|
|
|
|
class BinaryResponse:
|
|
def __init__(self, content: bytes, *, content_type: str = "application/xml"):
|
|
self.content = content
|
|
self.headers = {"Content-Type": content_type}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self, size=-1):
|
|
return self.content if size < 0 else self.content[:size]
|
|
|
|
|
|
class JsonResponse:
|
|
def __init__(self, payload, *, url="https://geo.api.vlaanderen.be/VRBG/items"):
|
|
self.payload = payload
|
|
self.url = url
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
|
|
class SourceSession:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def get(self, *_args, **_kwargs):
|
|
return JsonResponse(self.payload)
|
|
|
|
|
|
class VersionQuery:
|
|
def __init__(self, versions):
|
|
self.versions = versions
|
|
|
|
def filter(self, *_args):
|
|
return self
|
|
|
|
def all(self):
|
|
return self.versions
|
|
|
|
|
|
class FinalizeSession:
|
|
def __init__(self, rows, versions=None):
|
|
self.rows = rows
|
|
self.versions = versions or []
|
|
self.commit_count = 0
|
|
|
|
def get(self, model, row_id):
|
|
return self.rows.get((model, row_id))
|
|
|
|
def query(self, model):
|
|
assert model is DatasetVersion
|
|
return VersionQuery(self.versions)
|
|
|
|
def commit(self):
|
|
self.commit_count += 1
|
|
|
|
|
|
def capabilities_xml() -> bytes:
|
|
return b"""<?xml version="1.0"?>
|
|
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
|
|
<ContentMetadata>
|
|
<CoverageOfferingBrief>
|
|
<name>EL.GridCoverage</name>
|
|
<label>Belgian Continental Shelf bathymetry</label>
|
|
<lonLatEnvelope srsName="urn:ogc:def:crs:OGC:1.3:CRS84" />
|
|
</CoverageOfferingBrief>
|
|
</ContentMetadata>
|
|
<Capability><Request><GetCoverage><resultFormat><formats>GeoTIFF</formats></resultFormat></GetCoverage></Request></Capability>
|
|
</WCS_Capabilities>"""
|
|
|
|
|
|
def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
|
|
seen = {}
|
|
|
|
def opener(request, timeout):
|
|
seen["url"] = request.full_url
|
|
seen["timeout"] = timeout
|
|
return BinaryResponse(capabilities_xml())
|
|
|
|
result = MdkBathymetryProbeService.probe(
|
|
settings=Settings(_env_file=None),
|
|
opener=opener,
|
|
checked_at=datetime(2026, 7, 17, tzinfo=UTC),
|
|
)
|
|
|
|
assert result["status"] == "reachable"
|
|
assert result["tls_verified"] is True
|
|
assert result["capabilities_reachable"] is True
|
|
assert result["acquisition_supported"] is False
|
|
assert result["coverage_identifiers"] == ["EL.GridCoverage"]
|
|
assert result["advertised_formats"] == ["GeoTIFF"]
|
|
assert result["response_sha256"]
|
|
assert "request=GetCapabilities" in seen["url"]
|
|
assert seen["timeout"] == 20
|
|
|
|
|
|
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
|
|
calls = 0
|
|
|
|
def opener(_request, timeout):
|
|
nonlocal calls
|
|
assert timeout == 20
|
|
calls += 1
|
|
raise URLError(ssl.SSLCertVerificationError("hostname mismatch"))
|
|
|
|
result = MdkBathymetryProbeService.probe(
|
|
settings=Settings(_env_file=None),
|
|
opener=opener,
|
|
)
|
|
|
|
assert calls == 1
|
|
assert result["status"] == "tls_error"
|
|
assert result["tls_verified"] is False
|
|
assert result["capabilities_reachable"] is False
|
|
assert "insecure fallback is prohibited" in result["message"]
|
|
|
|
|
|
def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
|
|
project_id = uuid4()
|
|
db = SimpleNamespace(get=lambda model, row_id: Project(id=project_id, name="Mol") if model is Project else None)
|
|
monkeypatch.setattr(
|
|
MdkBathymetryProbeService,
|
|
"probe",
|
|
lambda: {
|
|
"source_key": "mdk_bcp_bathymetry",
|
|
"status": "tls_error",
|
|
"configured_url": "https://example.invalid/wcs",
|
|
"tls_verified": False,
|
|
"capabilities_reachable": False,
|
|
"acquisition_supported": False,
|
|
"checked_at": "2026-07-18T00:00:00Z",
|
|
"message": "TLS validation failed.",
|
|
"limitation_message": "No insecure fallback is permitted.",
|
|
},
|
|
)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
try:
|
|
response = TestClient(app).get(
|
|
f"/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness"
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
assert response.status_code == 200
|
|
assert set(response.json()) == {"data"}
|
|
assert response.json()["data"]["status"] == "tls_error"
|
|
assert response.json()["data"]["acquisition_supported"] is False
|
|
|
|
|
|
def test_partition_finalization_requires_complete_area_accounting_and_updates_versions() -> None:
|
|
project_id = uuid4()
|
|
area_ids = [uuid4(), uuid4()]
|
|
dataset_id = uuid4()
|
|
project = Project(id=project_id, name="Flanders")
|
|
areas = [
|
|
Area(id=area_ids[0], project_id=project_id, name="Gemeente Mol - officiële grens"),
|
|
Area(id=area_ids[1], project_id=project_id, name="Gemeente Geel - officiële grens"),
|
|
]
|
|
dataset = Dataset(
|
|
id=dataset_id,
|
|
project_id=project_id,
|
|
area_id=area_ids[0],
|
|
name="vha.geojson",
|
|
dataset_type="vector",
|
|
source="VHA",
|
|
source_name=BathymetryProfileAcquisitionService.PROVIDER,
|
|
source_metadata={
|
|
"profile_count": 3,
|
|
"document_count": 2,
|
|
"structured_depth_count": 1,
|
|
"measurement_date_min": "1990-01-01",
|
|
"measurement_date_max": "2020-01-01",
|
|
},
|
|
provenance_metadata={},
|
|
status="ready",
|
|
)
|
|
version = DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1)
|
|
rows = {
|
|
(Project, project_id): project,
|
|
(Area, area_ids[0]): areas[0],
|
|
(Area, area_ids[1]): areas[1],
|
|
(Dataset, dataset_id): dataset,
|
|
}
|
|
db = FinalizeSession(rows, [version])
|
|
payload = BathymetryPartitionFinalizeRequest(
|
|
partition_scope_key="flanders",
|
|
expected_area_ids=area_ids,
|
|
dataset_ids=[dataset_id],
|
|
no_profile_area_ids=[area_ids[1]],
|
|
manifest_sha256="a" * 64,
|
|
observed_at=datetime(2026, 7, 17, tzinfo=UTC),
|
|
)
|
|
|
|
result = BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload)
|
|
|
|
assert result["regional_partitions_complete"] is True
|
|
assert result["partition_count"] == 2
|
|
assert result["data_partition_count"] == 1
|
|
assert result["no_profile_partition_count"] == 1
|
|
assert result["profile_count"] == 3
|
|
assert dataset.source_metadata["coverage_scope"] == "flanders"
|
|
assert dataset.source_metadata["municipality"] == "Mol"
|
|
assert dataset.source_metadata["partitioned_source_audit"] is True
|
|
assert version.source_metadata == dataset.source_metadata
|
|
assert version.provenance_metadata == dataset.provenance_metadata
|
|
assert db.commit_count == 1
|
|
|
|
incomplete = payload.model_copy(update={"no_profile_area_ids": []})
|
|
with pytest.raises(AppError) as exc_info:
|
|
BathymetryProfileAcquisitionService.finalize_partitions(
|
|
FinalizeSession(rows, [version]),
|
|
project_id,
|
|
incomplete,
|
|
)
|
|
assert exc_info.value.code == "BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE"
|
|
|
|
|
|
def test_partition_selection_uses_latest_complete_manifest_without_duplicates() -> None:
|
|
project_id = uuid4()
|
|
source_name = BathymetryProfileAcquisitionService.PROVIDER
|
|
|
|
def partition(area_id, manifest, observed_at, data_partition_count):
|
|
return Dataset(
|
|
id=uuid4(),
|
|
project_id=project_id,
|
|
area_id=area_id,
|
|
name="vha.geojson",
|
|
dataset_type="vector",
|
|
source="VHA",
|
|
source_name=source_name,
|
|
source_metadata={
|
|
"regional_partitions_complete": True,
|
|
"partition_scope_key": "flanders",
|
|
"partition_manifest_sha256": manifest,
|
|
"partition_manifest_observed_at": observed_at,
|
|
"data_partition_count": data_partition_count,
|
|
},
|
|
status="ready",
|
|
)
|
|
|
|
old = [partition(uuid4(), "a" * 64, "2026-07-16T00:00:00+00:00", 1)]
|
|
new_area_ids = [uuid4(), uuid4()]
|
|
new = [
|
|
partition(area_id, "b" * 64, "2026-07-17T00:00:00+00:00", 2)
|
|
for area_id in new_area_ids
|
|
]
|
|
incomplete = [partition(uuid4(), "c" * 64, "2026-07-18T00:00:00+00:00", 2)]
|
|
|
|
selected = VectorFeatureService._latest_complete_partition_manifest(
|
|
[*old, *new, *incomplete],
|
|
source_name=source_name,
|
|
partition_scope_key="flanders",
|
|
)
|
|
|
|
assert {dataset.area_id for dataset in selected} == set(new_area_ids)
|
|
assert all(dataset.source_metadata["partition_manifest_sha256"] == "b" * 64 for dataset in selected)
|
|
|
|
|
|
def test_partition_selection_route_uses_exact_municipality_and_canonical_envelope(monkeypatch) -> None:
|
|
project_id, area_id, dataset_id = uuid4(), uuid4(), uuid4()
|
|
area_geometry = MultiPolygon(
|
|
[
|
|
Polygon(
|
|
[
|
|
(5.0, 51.0),
|
|
(5.2, 51.0),
|
|
(5.2, 51.2),
|
|
(5.0, 51.2),
|
|
(5.0, 51.0),
|
|
]
|
|
)
|
|
]
|
|
)
|
|
area = Area(
|
|
id=area_id,
|
|
project_id=project_id,
|
|
name="Gemeente Mol - officiele grens",
|
|
geometry=from_shape(area_geometry, srid=4326),
|
|
)
|
|
db = SimpleNamespace(get=lambda model, row_id: area if model is Area and row_id == area_id else None)
|
|
captured = {}
|
|
|
|
def select_partitions(_db, **kwargs):
|
|
captured.update(kwargs)
|
|
return {
|
|
"selection_bbox": kwargs["bbox"],
|
|
"selection_area_id": area_id,
|
|
"feature_count": 1,
|
|
"total_feature_count": 1,
|
|
"limit": kwargs["limit"],
|
|
"truncated": False,
|
|
"geojson": {"type": "FeatureCollection", "features": []},
|
|
"partition_count": 1,
|
|
"available_partition_count": 269,
|
|
"partition_scope_key": "flanders",
|
|
"source_name": BathymetryProfileAcquisitionService.PROVIDER,
|
|
"dataset_ids": [dataset_id],
|
|
}
|
|
|
|
monkeypatch.setattr(VectorFeatureService, "select_partitioned_features_by_bbox", select_partitions)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
try:
|
|
response = TestClient(app).post(
|
|
f"/api/v1/projects/{project_id}/datasets/bathymetry/profiles/partitions/select",
|
|
json={
|
|
"bbox": {
|
|
"min_x": 5.0,
|
|
"min_y": 51.0,
|
|
"max_x": 5.2,
|
|
"max_y": 51.2,
|
|
"crs": "EPSG:4326",
|
|
},
|
|
"area_id": str(area_id),
|
|
"limit": 1000,
|
|
},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
assert response.status_code == 200
|
|
assert set(response.json()) == {"data"}
|
|
assert response.json()["data"]["partition_count"] == 1
|
|
assert response.json()["data"]["available_partition_count"] == 269
|
|
assert captured["partition_area_id"] == area_id
|
|
assert captured["source_name"] == BathymetryProfileAcquisitionService.PROVIDER
|
|
assert captured["partition_scope_key"] == "flanders"
|
|
|
|
|
|
def test_flanders_scope_discovery_uses_complete_unique_vrbg_inventory() -> None:
|
|
features = [
|
|
{
|
|
"type": "Feature",
|
|
"id": f"Refgem.{index:05d}",
|
|
"properties": {"NISCODE": f"{index:05d}", "NAAM": f"Gemeente {index:03d}"},
|
|
"geometry": {
|
|
"type": "Polygon",
|
|
"coordinates": [[[4.0, 50.5], [4.1, 50.5], [4.1, 50.6], [4.0, 50.5]]],
|
|
},
|
|
}
|
|
for index in range(1, 286)
|
|
]
|
|
scope, selected, source_url = flanders_scope.discover_flanders_scope(
|
|
SourceSession({"features": list(reversed(features))}),
|
|
timeout=30,
|
|
min_municipalities=270,
|
|
max_municipalities=300,
|
|
)
|
|
|
|
assert scope.key == "flanders"
|
|
assert scope.project_name == "Flanders Regional Workbench"
|
|
assert len(scope.members) == 285
|
|
assert len(set(scope.nis_codes)) == 285
|
|
assert [item["properties"]["NISCODE"] for item in selected] == sorted(scope.nis_codes)
|
|
assert source_url.startswith("https://geo.api.vlaanderen.be/")
|
|
|
|
|
|
def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
|
|
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
|
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
|
for script in (
|
|
"provision_flanders_geographic_scope.py",
|
|
"provision_flanders_bathymetry_profiles.py",
|
|
"probe_mdk_bathymetry.py",
|
|
):
|
|
assert f"COPY scripts/{script}" in dockerfile
|
|
assert f"py_compile scripts/{script}" in readiness
|
|
|
|
sources = {
|
|
item["key"]: item
|
|
for item in BathymetryProfileAcquisitionService.list_sources()
|
|
}
|
|
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
|
# Bounded acquisition is implemented but remains disabled by default.
|
|
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
|
assert sources["mdk_bcp_bathymetry"]["configured"] is False
|
|
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
|
|
|
|
|
def test_frontend_exhaustively_pages_regional_area_inventory() -> None:
|
|
area_api = (ROOT / "frontend" / "src" / "services" / "api" / "areas.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
assert "const AREA_PAGE_SIZE = 200" in area_api
|
|
assert "while (offset < (total ?? 0))" in area_api
|
|
assert "items.length !== total" in area_api
|
|
assert "list: listProjectAreas" in area_api
|
|
|
|
|
|
def test_frontend_bounds_large_area_and_dataset_catalogs() -> None:
|
|
area_panel = (
|
|
ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx"
|
|
).read_text(encoding="utf-8")
|
|
dataset_panel = (
|
|
ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "const AREA_CATALOG_PAGE_SIZE = 12" in area_panel
|
|
assert "{catalogOpen ? (" in area_panel
|
|
assert "visibleAreas.map" in area_panel
|
|
assert "Zoek gemeente of regio" in area_panel
|
|
assert "const DATASET_CATALOG_PAGE_SIZE = 10" in dataset_panel
|
|
assert "visiblePrimaryDatasets.map" in dataset_panel
|
|
assert "Zoek in beschikbare bronnen" in dataset_panel
|
|
assert "{historyOpen ? <ul" in dataset_panel
|
|
|
|
|
|
def test_frontend_labels_flanders_scope_without_kempen_mislabeling() -> None:
|
|
dataset_display = (
|
|
ROOT / "frontend" / "src" / "lib" / "datasetDisplay.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "coverageScope === 'flanders' && layer === 'regional_boundary'" in dataset_display
|
|
assert "'Grens Vlaanderen'" in dataset_display
|
|
assert "coverageScope === 'flanders' && layer === 'municipality_boundaries'" in dataset_display
|
|
assert "'Gemeentegrenzen Vlaanderen'" in dataset_display
|
|
|
|
|
|
def test_frontend_uses_partitioned_bathymetry_selection_for_regional_scope() -> None:
|
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
|
index = (ROOT / "frontend" / "index.html").read_text(encoding="utf-8")
|
|
focus = (
|
|
ROOT / "frontend" / "src" / "config" / "primaryFocus.ts"
|
|
).read_text(encoding="utf-8")
|
|
map_workspace = read_map_workspace()
|
|
theme_hook = (
|
|
ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts"
|
|
).read_text(encoding="utf-8")
|
|
dataset_api = (
|
|
ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "isPartitionedBathymetry" in map_workspace
|
|
assert "regionalPartitionedThemeActive" in map_workspace
|
|
assert "availabilityLabel" in map_workspace
|
|
assert "activeSelectionResult?.geojson ?? null" in map_workspace
|
|
assert "selectBathymetryProfilePartitions" in theme_hook
|
|
assert "datasets/bathymetry/profiles/partitions/select" in dataset_api
|
|
assert "regionalBathymetryContextActive" in app
|
|
assert "regionalBathymetryProfileCount" in app
|
|
assert "profielen · ${regionalBathymetryPartitions.length} gemeenten" in app
|
|
assert "FLANDERS_WORKSPACE_LABEL = 'Vlaanderen (285 gemeenten)'" in focus
|
|
assert "<title>GeoIntel</title>" in index
|