414 lines
15 KiB
Python
414 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
from shapely.geometry import box
|
|
|
|
from app.core.errors import AppError
|
|
from app.main import app
|
|
from app.models import Area, Dataset, Project
|
|
from app.schemas.coverage import CoverageBBox
|
|
from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
|
|
|
|
class FakeQuery:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
|
|
def filter(self, *_args, **_kwargs):
|
|
return self
|
|
|
|
def all(self):
|
|
return list(self.rows)
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, *, project, areas, datasets):
|
|
self.project = project
|
|
self.areas = areas
|
|
self.datasets = datasets
|
|
|
|
def get(self, model, object_id):
|
|
if model is Project and str(self.project.id) == str(object_id):
|
|
return self.project
|
|
return None
|
|
|
|
def query(self, model):
|
|
if model is Area:
|
|
return FakeQuery(self.areas)
|
|
if model is Dataset:
|
|
return FakeQuery(self.datasets)
|
|
raise AssertionError(f"Unexpected query model: {model}")
|
|
|
|
|
|
def scope_area(name: str, geometry):
|
|
return SimpleNamespace(name=name, geometry=geometry)
|
|
|
|
|
|
def test_coverage_catalog_uses_normalized_contracts_and_does_not_change_provider_registry() -> None:
|
|
catalog = CoverageRegistryService.catalog()
|
|
|
|
assert set(catalog.themes) == set(THEMES)
|
|
assert set(catalog.zones) == set(ZONES)
|
|
assert catalog.statuses == ["unsupported", "not_configured", "partial", "operational"]
|
|
assert {source.source_name for source in catalog.sources} >= {
|
|
"ngi_adminvector",
|
|
"statbel",
|
|
"digitaal_vlaanderen",
|
|
"spw_geoportail",
|
|
"urbis",
|
|
"rbins_marine_reporting_units",
|
|
"rbins_msp_2026",
|
|
"mdk_bathymetry",
|
|
"vmm_vha_bathymetry_profiles",
|
|
}
|
|
assert next(source for source in catalog.sources if source.source_name == "ngi_adminvector").license_note == "CC BY 4.0"
|
|
assert next(source for source in catalog.sources if source.source_name == "mdk_bathymetry").integration_status == "not_configured"
|
|
assert next(
|
|
source for source in catalog.sources if source.source_name == "vmm_vha_bathymetry_profiles"
|
|
).integration_status == "operational"
|
|
|
|
response = TestClient(app).get("/api/v1/external/coverage/catalog")
|
|
assert response.status_code == 200
|
|
assert response.json()["data"]["themes"] == list(THEMES)
|
|
|
|
|
|
def test_national_and_maritime_reference_layers_are_selection_analyzable() -> None:
|
|
cases = (
|
|
("ngi_adminvector", "belgium_municipalities", "administrative"),
|
|
("rbins_marine_reporting_units", "marine_legal_scopes", "marine_environment"),
|
|
("rbins_msp_2026", "marine_spatial_plan_2026", "maritime_planning"),
|
|
)
|
|
for source_name, layer_name, expected_theme in cases:
|
|
dataset = Dataset(
|
|
id=uuid4(),
|
|
project_id=uuid4(),
|
|
name=f"{layer_name}.geojson",
|
|
dataset_type="vector",
|
|
source="operator_official_import",
|
|
source_name=source_name,
|
|
reference_layer_name=layer_name,
|
|
source_metadata={"authority_level": "authoritative"},
|
|
status="ready",
|
|
)
|
|
|
|
assert VectorFeatureService._dataset_theme(dataset) == expected_theme
|
|
assert VectorFeatureService.supports_selection_summary(dataset) is True
|
|
|
|
|
|
def test_national_scope_operator_assigns_explicit_map_themes() -> None:
|
|
root = Path(__file__).resolve().parents[2]
|
|
operator = (root / "scripts" / "provision_belgium_north_sea_scope.py").read_text(encoding="utf-8")
|
|
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
assert '"belgium_municipalities": "administrative"' in operator
|
|
assert '"marine_legal_scopes": "marine_environment"' in operator
|
|
assert '"marine_spatial_plan_2026": "maritime_planning"' in operator
|
|
assert "id: 'administrative'" in map_workspace
|
|
assert "id: 'maritime_planning'" in map_workspace
|
|
assert "id: 'marine_environment'" in map_workspace
|
|
|
|
|
|
def test_coverage_resolver_only_reports_operational_for_materialized_ready_dataset() -> None:
|
|
project_id = uuid4()
|
|
project = SimpleNamespace(id=project_id)
|
|
areas = [
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
|
]
|
|
bbox = CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0)
|
|
|
|
without_materialized = CoverageRegistryService.resolve(
|
|
FakeSession(project=project, areas=areas, datasets=[]),
|
|
project_id,
|
|
bbox,
|
|
["admin"],
|
|
)
|
|
assert without_materialized.intersected_zones == ["flanders"]
|
|
assert without_materialized.items[0].status == "partial"
|
|
assert without_materialized.items[0].materialized_dataset_ids == []
|
|
|
|
dataset_id = uuid4()
|
|
materialized = SimpleNamespace(
|
|
id=dataset_id,
|
|
status="ready",
|
|
source_name="ngi_adminvector",
|
|
reference_layer_name="belgium_regions",
|
|
source_metadata={"coverage_zones": ["belgium", "flanders", "wallonia", "brussels"]},
|
|
)
|
|
with_materialized = CoverageRegistryService.resolve(
|
|
FakeSession(project=project, areas=areas, datasets=[materialized]),
|
|
project_id,
|
|
bbox,
|
|
["admin"],
|
|
)
|
|
admin_item = next(item for item in with_materialized.items if item.zone == "flanders")
|
|
assert admin_item.status == "operational"
|
|
assert admin_item.materialized_dataset_ids == [dataset_id]
|
|
|
|
|
|
def test_statbel_population_materialization_does_not_masquerade_as_admin_data() -> None:
|
|
project_id = uuid4()
|
|
statbel_id = uuid4()
|
|
statbel = SimpleNamespace(
|
|
id=statbel_id,
|
|
status="ready",
|
|
source_name="statbel",
|
|
reference_layer_name="population",
|
|
source_metadata={"coverage_zones": ["belgium", "flanders", "wallonia", "brussels"]},
|
|
)
|
|
result = CoverageRegistryService.resolve(
|
|
FakeSession(
|
|
project=SimpleNamespace(id=project_id),
|
|
areas=[
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
|
],
|
|
datasets=[statbel],
|
|
),
|
|
project_id,
|
|
CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0),
|
|
["admin", "population"],
|
|
)
|
|
|
|
admin = next(item for item in result.items if item.theme == "admin")
|
|
population = next(item for item in result.items if item.theme == "population")
|
|
assert admin.materialized_dataset_ids == []
|
|
assert population.status == "operational"
|
|
assert population.materialized_dataset_ids == [statbel_id]
|
|
|
|
|
|
def test_bounded_api_materialization_only_covers_its_persisted_bbox() -> None:
|
|
project_id = uuid4()
|
|
dataset_id = uuid4()
|
|
dataset = SimpleNamespace(
|
|
id=dataset_id,
|
|
status="ready",
|
|
source_name="spw_picc",
|
|
reference_layer_name="buildings",
|
|
source_metadata={
|
|
"coverage_zones": ["wallonia"],
|
|
"bbox_epsg4326": [4.55, 50.58, 4.56, 50.59],
|
|
},
|
|
)
|
|
session = FakeSession(
|
|
project=SimpleNamespace(id=project_id),
|
|
areas=[
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Wallonia", box(2.5, 49.5, 6.4, 50.8)),
|
|
],
|
|
datasets=[dataset],
|
|
)
|
|
|
|
inside = CoverageRegistryService.resolve(
|
|
session,
|
|
project_id,
|
|
CoverageBBox(minx=4.551, miny=50.581, maxx=4.559, maxy=50.589),
|
|
["buildings"],
|
|
)
|
|
outside = CoverageRegistryService.resolve(
|
|
session,
|
|
project_id,
|
|
CoverageBBox(minx=4.7, miny=50.6, maxx=4.71, maxy=50.61),
|
|
["buildings"],
|
|
)
|
|
|
|
assert inside.items[0].status == "operational"
|
|
assert inside.items[0].materialized_dataset_ids == [dataset_id]
|
|
assert outside.items[0].status == "partial"
|
|
assert outside.items[0].materialized_dataset_ids == []
|
|
|
|
|
|
def test_spw_bathymetry_materialization_is_source_specific() -> None:
|
|
project_id = uuid4()
|
|
scope = [
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Wallonia", box(2.5, 49.5, 6.4, 50.8)),
|
|
]
|
|
selection = CoverageBBox(minx=4.851, miny=50.451, maxx=4.869, maxy=50.469)
|
|
spw_picc = SimpleNamespace(
|
|
id=uuid4(),
|
|
status="ready",
|
|
source_name="spw_picc",
|
|
reference_layer_name="buildings",
|
|
source_metadata={
|
|
"coverage_zones": ["wallonia"],
|
|
"bbox_epsg4326": [4.85, 50.45, 4.87, 50.47],
|
|
},
|
|
)
|
|
without_bathymetry = CoverageRegistryService.resolve(
|
|
FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[spw_picc]),
|
|
project_id,
|
|
selection,
|
|
["bathymetry"],
|
|
)
|
|
assert without_bathymetry.items[0].status == "partial"
|
|
assert without_bathymetry.items[0].materialized_dataset_ids == []
|
|
|
|
bathymetry_id = uuid4()
|
|
bathymetry = SimpleNamespace(
|
|
id=bathymetry_id,
|
|
status="ready",
|
|
source_name="spw_bathymetry",
|
|
reference_layer_name=None,
|
|
source_metadata={
|
|
"coverage_zones": ["wallonia"],
|
|
"bbox_epsg4326": [4.85, 50.45, 4.87, 50.47],
|
|
},
|
|
)
|
|
with_bathymetry = CoverageRegistryService.resolve(
|
|
FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[spw_picc, bathymetry]),
|
|
project_id,
|
|
selection,
|
|
["bathymetry"],
|
|
)
|
|
assert with_bathymetry.items[0].status == "operational"
|
|
assert with_bathymetry.items[0].materialized_dataset_ids == [bathymetry_id]
|
|
|
|
|
|
def test_vha_bathymetry_profiles_are_operational_only_inside_the_persisted_selection() -> None:
|
|
project_id = uuid4()
|
|
scope = [
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
|
]
|
|
selection = CoverageBBox(minx=5.101, miny=51.171, maxx=5.109, maxy=51.179)
|
|
without_profiles = CoverageRegistryService.resolve(
|
|
FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[]),
|
|
project_id,
|
|
selection,
|
|
["bathymetry"],
|
|
)
|
|
assert without_profiles.items[0].status == "partial"
|
|
assert without_profiles.items[0].source_names == ["vmm_vha_bathymetry_profiles"]
|
|
|
|
profile_id = uuid4()
|
|
profiles = SimpleNamespace(
|
|
id=profile_id,
|
|
status="ready",
|
|
source_name="vmm_vha_bathymetry_profiles",
|
|
reference_layer_name="bathymetry_profile_points",
|
|
source_metadata={
|
|
"coverage_zones": ["flanders"],
|
|
"bbox_epsg4326": [5.1, 51.17, 5.11, 51.18],
|
|
},
|
|
)
|
|
with_profiles = CoverageRegistryService.resolve(
|
|
FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[profiles]),
|
|
project_id,
|
|
selection,
|
|
["bathymetry"],
|
|
)
|
|
assert with_profiles.items[0].status == "operational"
|
|
assert with_profiles.items[0].materialized_dataset_ids == [profile_id]
|
|
|
|
|
|
def test_mixed_land_and_north_sea_selection_remains_split() -> None:
|
|
project_id = uuid4()
|
|
project = SimpleNamespace(id=project_id)
|
|
areas = [
|
|
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
|
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
|
scope_area("Belgian part of the North Sea", box(2.2, 51.1, 3.4, 51.9)),
|
|
scope_area("Belgian territorial sea (0-12 nautical miles)", box(2.7, 51.1, 3.4, 51.5)),
|
|
scope_area("Belgian exclusive economic zone beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
|
|
scope_area("Belgian continental shelf beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
|
|
]
|
|
|
|
result = CoverageRegistryService.resolve(
|
|
FakeSession(project=project, areas=areas, datasets=[]),
|
|
project_id,
|
|
CoverageBBox(minx=2.65, miny=51.05, maxx=2.85, maxy=51.2),
|
|
["admin", "bathymetry"],
|
|
)
|
|
|
|
assert result.intersected_zones == ["flanders", "territorial_sea"]
|
|
assert len(result.items) == 4
|
|
assert any("crosses coverage zones" in warning for warning in result.warnings)
|
|
bathymetry = next(item for item in result.items if item.zone == "territorial_sea" and item.theme == "bathymetry")
|
|
assert bathymetry.status == "not_configured"
|
|
assert bathymetry.materialized_dataset_ids == []
|
|
|
|
|
|
def test_flemish_materialization_is_theme_specific() -> None:
|
|
project_id = uuid4()
|
|
project = SimpleNamespace(id=project_id)
|
|
orthophoto_id = uuid4()
|
|
orthophoto = SimpleNamespace(
|
|
id=orthophoto_id,
|
|
status="ready",
|
|
source_name="digitaal_vlaanderen_orthophoto",
|
|
reference_layer_name="orthophoto",
|
|
source_metadata={"coverage_zones": ["flanders"]},
|
|
)
|
|
result = CoverageRegistryService.resolve(
|
|
FakeSession(
|
|
project=project,
|
|
areas=[scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5))],
|
|
datasets=[orthophoto],
|
|
),
|
|
project_id,
|
|
CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0),
|
|
["orthophoto", "roads"],
|
|
)
|
|
|
|
assert next(item for item in result.items if item.theme == "orthophoto").status == "operational"
|
|
assert next(item for item in result.items if item.theme == "roads").status == "partial"
|
|
|
|
|
|
def test_outside_scope_and_unknown_theme_are_explicit() -> None:
|
|
project_id = uuid4()
|
|
project = SimpleNamespace(id=project_id)
|
|
db = FakeSession(
|
|
project=project,
|
|
areas=[scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5))],
|
|
datasets=[],
|
|
)
|
|
result = CoverageRegistryService.resolve(
|
|
db,
|
|
project_id,
|
|
CoverageBBox(minx=7.0, miny=52.0, maxx=7.1, maxy=52.1),
|
|
["admin"],
|
|
)
|
|
assert result.intersected_zones == []
|
|
assert result.outside_supported_scope is True
|
|
assert result.items == []
|
|
|
|
try:
|
|
CoverageRegistryService.resolve(
|
|
db,
|
|
project_id,
|
|
CoverageBBox(minx=4.0, miny=50.0, maxx=4.1, maxy=50.1),
|
|
["invented_metric"],
|
|
)
|
|
except AppError as exc:
|
|
assert exc.code == "COVERAGE_THEME_UNSUPPORTED"
|
|
assert exc.status_code == 422
|
|
assert exc.details["unsupported_themes"] == ["invented_metric"]
|
|
else:
|
|
raise AssertionError("Unknown coverage theme was accepted")
|
|
|
|
|
|
def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbox() -> None:
|
|
root = Path(__file__).parents[2]
|
|
focus = (root / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
|
|
workspace_hook = (root / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8")
|
|
coverage_hook = (root / "frontend" / "src" / "hooks" / "useCoverageResolver.ts").read_text(encoding="utf-8")
|
|
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
|
|
|
assert "Belgium and North Sea Workbench" in focus
|
|
assert "nationalProject" in workspace_hook
|
|
assert "data.areas.length > 0" in workspace_hook
|
|
assert "dataset.status === 'ready'" in workspace_hook
|
|
assert "externalApi.resolveCoverage" in coverage_hook
|
|
assert "coverage.outside_supported_scope" in map_workspace
|
|
assert "coverageStatusLabel" in map_workspace
|
|
assert "coverageSelectionAvailable" in map_workspace
|
|
assert "activeThemeAvailable && !regionalPartitionedThemeActive" in map_workspace
|