Constrain selections to active work areas
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user