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 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", } 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" response = TestClient(app).get("/api/v1/external/coverage/catalog") assert response.status_code == 200 assert response.json()["data"]["themes"] == list(THEMES) 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_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