Constrain selections to active work areas
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:58:38 +02:00
parent a29c787238
commit d652db6a1e
20 changed files with 329 additions and 71 deletions
@@ -4,8 +4,8 @@ import uuid
from pathlib import Path
from types import SimpleNamespace
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon, box
from app.core.errors import AppError
from app.models import Dataset, VectorFeature
@@ -176,7 +176,7 @@ def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeyp
name="Regional vector",
source_metadata={"selection_aggregation": {"method": "feature_count"}},
)
area_geometry = object()
area_geometry = from_shape(box(5.0, 51.0, 5.3, 51.3), srid=4326)
area = SimpleNamespace(id=area_id, project_id=project_id, geometry=area_geometry)
captured: dict[str, object] = {}
@@ -226,9 +226,10 @@ def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeyp
)
assert str(response["data"]["selection_area_id"]) == str(area_id)
assert captured["select"]["selection_geometry"] is area_geometry
assert to_shape(captured["select"]["selection_geometry"]).equals(box(5.0, 51.0, 5.2, 51.2))
assert captured["select"]["selection_area_id"] == area_id
assert captured["summary"]["selection_geometry"] is area_geometry
assert to_shape(captured["summary"]["selection_geometry"]).equals(box(5.0, 51.0, 5.2, 51.2))
assert captured["select"]["full_dataset_area"] is False
def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> None:
@@ -6,7 +6,10 @@ from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import box
from app.core.errors import AppError
from app.main import app
from app.models import Area, Dataset, Export
from app.schemas.export import ExportCreateResponse
@@ -140,7 +143,8 @@ def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path,
source="fixture",
status="ready",
)
area_geometry = object()
area_shape = box(5.0, 51.1, 5.2, 51.3)
area_geometry = from_shape(area_shape, srid=4326)
area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
@@ -170,12 +174,78 @@ def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path,
area_id=area_id,
)
assert captured["selection_geometry"] is area_geometry
assert to_shape(captured["selection_geometry"]).equals(area_shape)
assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is True
assert response.metadata_json["selection_area_id"] == str(area_id)
def test_area_constrained_bbox_uses_intersection_and_disables_full_area_fast_path(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name="mol-buildings.geojson",
dataset_type="vector",
source="fixture",
source_metadata={"geometry_clipped_to_area": True},
status="ready",
)
area_shape = box(5.0, 51.0, 5.2, 51.2)
area = SimpleNamespace(
id=area_id,
project_id=project_id,
name="Gemeente Mol - officiële grens",
geometry=from_shape(area_shape, srid=4326),
)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
crossing_bbox = {"min_x": 4.9, "min_y": 51.1, "max_x": 5.1, "max_y": 51.3, "crs": "EPSG:4326"}
captured: dict = {}
selection_payload = {
"selection_bbox": crossing_bbox,
"selection_area_id": str(area_id),
"feature_count": 0,
"limit": 250,
"truncated": False,
"geojson": {"type": "FeatureCollection", "features": []},
}
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "selection.geojson"))
def fake_select(*_args, **kwargs):
captured.update(kwargs)
return selection_payload
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", fake_select)
ExportService.export_vector_selection_geojson(
db,
dataset_id,
crossing_bbox,
area_id=area_id,
)
assert to_shape(captured["selection_geometry"]).equals(box(5.0, 51.1, 5.1, 51.2))
assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is False
def test_area_constrained_bbox_rejects_selection_outside_work_area() -> None:
area_geometry = from_shape(box(5.0, 51.0, 5.2, 51.2), srid=4326)
outside_bbox = {"min_x": 4.0, "min_y": 50.0, "max_x": 4.1, "max_y": 50.1, "crs": "EPSG:4326"}
try:
VectorFeatureService.constrain_bbox_to_area(outside_bbox, area_geometry)
except AppError as error:
assert error.code == "VECTOR_SELECTION_OUTSIDE_AREA"
assert error.status_code == 422
else:
raise AssertionError("Expected an outside-area selection to be rejected")
def test_frontend_exposes_map_selection_export_action() -> None:
types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
exports_api = (ROOT / "frontend" / "src" / "services" / "api" / "exports.ts").read_text(encoding="utf-8")
@@ -36,9 +36,9 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
assert "void analyzeSelection(bbox)" in workspace
assert "void analyzeSelection(bbox, areaIdForSelection(bbox))" in workspace
assert "const areaIdForSelection" in workspace
assert "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace
assert "bbox && selectedMapArea ? selectedMapArea.id : undefined" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "map.on('mousedown'" in geomap
assert "map.on('mousemove'" in geomap
@@ -6,8 +6,8 @@ from types import SimpleNamespace
from uuid import uuid4
import pytest
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon, box
from app.core.errors import AppError
from app.models import Dataset, DatasetVersion
@@ -347,6 +347,73 @@ def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch
assert result.geojson["type"] == "FeatureCollection"
def test_temporal_comparison_clips_cross_boundary_bbox_to_selected_area(monkeypatch) -> None:
project_id = uuid4()
area_id = uuid4()
earlier = temporal_dataset(project_id=project_id, observed_year=2021)
later = temporal_dataset(project_id=project_id, observed_year=2024)
area_shape = box(5.0, 51.0, 5.2, 51.2)
area = SimpleNamespace(
id=area_id,
project_id=project_id,
geometry=from_shape(area_shape, srid=4326),
)
captured_geometries = []
identity_capture = {}
monkeypatch.setattr(
TemporalAnalysisService,
"_get_temporal_dataset",
staticmethod(lambda _db, _project_id, dataset_id, _label: earlier if dataset_id == earlier.id else later),
)
monkeypatch.setattr(
TemporalAnalysisService,
"_get_selection_area",
staticmethod(lambda _db, _project_id, requested_area_id: area if requested_area_id == area_id else None),
)
def summarize(_db, *, dataset, bbox, selection_geometry, full_dataset_area): # noqa: ARG001
captured_geometries.append(selection_geometry)
return {
"metric_label": "Oppervlakte",
"metric_value": 10.0 if dataset.id == earlier.id else 12.0,
"metric_unit": "ha",
"aggregation_method": "intersection_area",
"feature_count": 1,
"is_estimate": False,
"warning": None,
}
def compare_identity(*_args, **kwargs):
identity_capture.update(kwargs)
return (
TemporalObjectChanges(available=False),
{"type": "FeatureCollection", "features": []},
[],
)
monkeypatch.setattr(VectorFeatureService, "summarize_features_by_bbox", staticmethod(summarize))
monkeypatch.setattr(TemporalAnalysisService, "_compare_identity_features", staticmethod(compare_identity))
result = TemporalAnalysisService.compare(
SimpleNamespace(),
project_id=project_id,
payload=TemporalComparisonRequest(
earlier_dataset_id=earlier.id,
later_dataset_id=later.id,
area_id=area_id,
bbox={"min_x": 4.9, "min_y": 51.1, "max_x": 5.1, "max_y": 51.3},
),
)
expected = box(5.0, 51.1, 5.1, 51.2)
assert result.metric.absolute_change == 2.0
assert all(to_shape(geometry).equals(expected) for geometry in captured_geometries)
assert to_shape(identity_capture["selection_geometry"]).equals(expected)
assert identity_capture["earlier_full_dataset_area"] is False
assert identity_capture["later_full_dataset_area"] is False
def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None:
project_id = uuid4()
earlier = temporal_dataset(project_id=project_id, observed_year=2021)
@@ -33,7 +33,7 @@ def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None
assert "/datasets/raster/terrain/select" in api
assert "/datasets/raster/flood-hazard/select" in api
assert "Rasterlaag actief" in app
assert "void analyzeSelection(bbox)" in workspace
assert "void analyzeSelection(bbox, areaIdForSelection(bbox))" in workspace
assert "areaIdForSelection(bbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace
@@ -326,6 +326,21 @@ def test_map_and_detection_workspaces_avoid_page_length_driven_layouts() -> None
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")