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>
273 lines
9.6 KiB
Python
273 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from shapely.geometry import box, shape
|
|
from shapely.ops import transform as transform_geometry
|
|
|
|
from app.models import Dataset
|
|
from app.schemas.operations import VectorSelectionSummary
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
from tests.frontend_contract import read_map_workspace
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
|
|
|
|
|
|
def load_operator():
|
|
script_path = ROOT / "scripts" / "provision_mol_bwk_natura2000.py"
|
|
spec = importlib.util.spec_from_file_location("bwk_natura2000_operator", script_path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class FakeResponse:
|
|
ok = True
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def __init__(self, payload: dict, url: str):
|
|
self.payload = payload
|
|
self.url = url
|
|
self.content = json.dumps(payload).encode("utf-8")
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, responses: list[FakeResponse]):
|
|
self.responses = iter(responses)
|
|
self.calls: list[tuple[str, dict | None]] = []
|
|
|
|
def get(self, url, *, params=None, timeout): # noqa: ANN001, ARG002
|
|
self.calls.append((url, params))
|
|
return next(self.responses)
|
|
|
|
|
|
class ScalarQuery:
|
|
def __init__(self, value: float):
|
|
self.value = value
|
|
|
|
def filter(self, *args): # noqa: ANN002, ARG002
|
|
return self
|
|
|
|
def scalar(self):
|
|
return self.value
|
|
|
|
|
|
class SequenceScalarSession:
|
|
def __init__(self, values: list[float]):
|
|
self.values = iter(values)
|
|
|
|
def query(self, *args): # noqa: ANN002, ARG002
|
|
return ScalarQuery(next(self.values))
|
|
|
|
|
|
def test_official_bwk_contract_and_class_labels_are_fixed() -> None:
|
|
module = load_operator()
|
|
|
|
assert module.WFS_URL == "https://geo.api.vlaanderen.be/BWK/wfs"
|
|
assert module.TYPE_NAME == "BWK:Bwkhab"
|
|
assert module.SOURCE_VERSION == "2025"
|
|
assert module.TEMPORAL_SERIES_KEY == "inbo-bwk-natura2000:mol"
|
|
assert module.ATTRIBUTION == "Bron: INBO"
|
|
assert module.EVALUATION_LABELS == {
|
|
"z": "Biologisch zeer waardevol",
|
|
"w": "Biologisch waardevol",
|
|
"m": "Biologisch minder waardevol",
|
|
"wz": "Complex van waardevolle en zeer waardevolle elementen",
|
|
"mwz": "Complex van minder waardevolle, waardevolle en zeer waardevolle elementen",
|
|
"mz": "Complex van minder waardevolle en zeer waardevolle elementen",
|
|
"mw": "Complex van minder waardevolle en waardevolle elementen",
|
|
}
|
|
|
|
|
|
def test_wfs_pagination_follows_server_next_links() -> None:
|
|
module = load_operator()
|
|
page_one = {
|
|
"type": "FeatureCollection",
|
|
"features": [{"id": "one"}],
|
|
"numberReturned": 1,
|
|
"links": [{"rel": "next", "href": "https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1"}],
|
|
}
|
|
page_two = {"type": "FeatureCollection", "features": [], "numberReturned": 0, "links": []}
|
|
session = FakeSession(
|
|
[
|
|
FakeResponse(page_one, "https://geo.api.vlaanderen.be/BWK/wfs?first"),
|
|
FakeResponse(page_two, "https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1"),
|
|
]
|
|
)
|
|
|
|
pages = list(module.iter_wfs_pages(session, (5.0, 51.1, 5.2, 51.3), page_limit=1000, timeout=30))
|
|
|
|
assert len(pages) == 2
|
|
assert session.calls[0][1]["sortBy"] == "UIDN"
|
|
assert session.calls[0][1]["srsName"] == "EPSG:4326"
|
|
assert session.calls[1] == ("https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1", None)
|
|
|
|
|
|
def test_wfs_page_limit_without_next_link_uses_controlled_start_index_fallback() -> None:
|
|
module = load_operator()
|
|
session = FakeSession(
|
|
[
|
|
FakeResponse(
|
|
{"type": "FeatureCollection", "features": [{"id": "one"}], "numberReturned": 1},
|
|
"https://geo.api.vlaanderen.be/BWK/wfs",
|
|
),
|
|
FakeResponse(
|
|
{"type": "FeatureCollection", "features": [], "numberReturned": 0},
|
|
"https://geo.api.vlaanderen.be/BWK/wfs?startIndex=1",
|
|
),
|
|
]
|
|
)
|
|
|
|
pages = list(module.iter_wfs_pages(session, (5.0, 51.1, 5.2, 51.3), page_limit=1, timeout=30))
|
|
|
|
assert len(pages) == 2
|
|
assert session.calls[1][1]["startIndex"] == "1"
|
|
|
|
|
|
def test_feature_is_clipped_in_lambert72_and_keeps_bwk_habitat_provenance() -> None:
|
|
module = load_operator()
|
|
boundary_wgs84 = box(5.10, 51.20, 5.11, 51.21)
|
|
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary_wgs84)
|
|
feature = {
|
|
"type": "Feature",
|
|
"id": "Bwkhab.42",
|
|
"geometry": mapping_box(5.095, 51.195, 5.105, 51.205),
|
|
"properties": {
|
|
"UIDN": 42,
|
|
"EVAL": "wz",
|
|
"BWKLABEL": "qb + qs",
|
|
"EENH1": "qb",
|
|
"EENH2": "qs",
|
|
"HERK": "225",
|
|
"HAB1": "9190",
|
|
"PHAB1": 60,
|
|
"HAB2": "rbbppm",
|
|
"PHAB2": 30,
|
|
"HAB3": "gh",
|
|
"PHAB3": 10,
|
|
"HABLEGENDE": "phab",
|
|
"HERKHAB": "225",
|
|
"HERKPHAB": "a",
|
|
},
|
|
}
|
|
|
|
normalized, was_clipped = module.normalize_feature(feature, boundary_lambert72)
|
|
|
|
assert normalized is not None
|
|
assert was_clipped is True
|
|
normalized_geometry = shape(normalized["geometry"])
|
|
assert normalized_geometry.difference(boundary_wgs84.buffer(1e-7)).area < 1e-12
|
|
properties = normalized["properties"]
|
|
assert properties["bwk_evaluation_code"] == "wz"
|
|
assert properties["bwk_evaluation_label"].startswith("Complex van waardevolle")
|
|
assert properties["natura2000_codes"] == "9190"
|
|
assert properties["regional_biotope_codes"] == "rbbppm"
|
|
assert properties["natura2000_area_ha"] == pytest.approx(properties["clipped_area_ha"] * 0.6)
|
|
assert properties["regional_biotope_area_ha"] == pytest.approx(properties["clipped_area_ha"] * 0.3)
|
|
assert properties["habitat_share_origin_code"] == "a"
|
|
|
|
|
|
def mapping_box(min_x: float, min_y: float, max_x: float, max_y: float) -> dict:
|
|
return {
|
|
"type": "Polygon",
|
|
"coordinates": [[
|
|
[min_x, min_y],
|
|
[max_x, min_y],
|
|
[max_x, max_y],
|
|
[min_x, max_y],
|
|
[min_x, min_y],
|
|
]],
|
|
}
|
|
|
|
|
|
def test_uncertain_habitat_status_is_not_presented_as_confirmed_habitat() -> None:
|
|
module = load_operator()
|
|
|
|
entries, natura_share, regional_share, uncertain_share = module.habitat_breakdown(
|
|
{"HAB1": "gh", "PHAB1": 100, "HABLEGENDE": "ohab"}
|
|
)
|
|
|
|
assert entries == [{"code": "gh", "share_percent": 100.0}]
|
|
assert natura_share == 0
|
|
assert regional_share == 0
|
|
assert uncertain_share == 100
|
|
|
|
|
|
def test_nature_value_summary_returns_separate_official_classes_and_habitat_metrics() -> None:
|
|
module = load_operator()
|
|
dataset = Dataset(
|
|
id=uuid4(),
|
|
project_id=uuid4(),
|
|
name="bwk_natura2000_2025_mol.geojson",
|
|
dataset_type="vector",
|
|
dataset_role="reference",
|
|
source_name="inbo_bwk_natura2000",
|
|
reference_layer_name="nature_value",
|
|
source_metadata={
|
|
"theme": "nature_value",
|
|
"semantic_metrics": False,
|
|
"selection_aggregation": {
|
|
"metric_key": "bwk_mapped_area",
|
|
"method": "intersection_area",
|
|
"label": "BWK-gekarteerde oppervlakte",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
},
|
|
"selection_metrics": module.selection_metrics(),
|
|
},
|
|
)
|
|
|
|
result = VectorFeatureService.summarize_features_by_bbox(
|
|
SequenceScalarSession([100_000.0, 10_000.0, 20_000.0, 30_000.0, 40_000.0, 5.5, 2.5, 1.5]),
|
|
dataset=dataset,
|
|
bbox=BBOX,
|
|
total_feature_count=125,
|
|
full_dataset_area=True,
|
|
)
|
|
|
|
assert result["primary_metric_key"] == "bwk_mapped_area"
|
|
assert result["metric_value"] == 10.0
|
|
metrics = {item["metric_key"]: item for item in result["metrics"]}
|
|
assert metrics["bwk_very_valuable_area"]["metric_value"] == 1.0
|
|
assert metrics["bwk_valuable_area"]["metric_value"] == 2.0
|
|
assert metrics["bwk_less_valuable_area"]["metric_value"] == 3.0
|
|
assert metrics["bwk_mixed_value_area"]["metric_value"] == 4.0
|
|
assert metrics["natura2000_area"]["metric_value"] == 5.5
|
|
assert metrics["natura2000_area"]["is_estimate"] is True
|
|
assert metrics["regional_biotope_area"]["metric_value"] == 2.5
|
|
assert metrics["uncertain_habitat_area"]["metric_value"] == 1.5
|
|
assert metrics["feature_count"]["metric_value"] == 125
|
|
VectorSelectionSummary(**result)
|
|
|
|
|
|
def test_operator_is_packaged_readiness_checked_and_wired_to_map() -> 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")
|
|
service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
|
|
map_workspace = read_map_workspace()
|
|
source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8")
|
|
|
|
assert "COPY scripts/provision_mol_bwk_natura2000.py" in dockerfile
|
|
assert "py_compile scripts/provision_mol_bwk_natura2000.py" in readiness
|
|
assert '"provision_mol_bwk_natura2000.py"' in service
|
|
assert "id: 'nature_value'" in map_workspace
|
|
assert "Natuurwaarde" in map_workspace
|
|
assert "source.key === 'bwk'" in source_catalog
|