Constrain selections to active work areas
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
FastAPI backend for GeoIntel Kempen Foundation Sprints.
|
||||
|
||||
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. Drawn/manual selections send only their bbox; `area_id` is reserved for the explicit full-work-area operation. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
|
||||
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. When an active `area_id` is supplied, vector, temporal, derived-dataset and export paths all use `bbox ∩ Area`. A full-work-area bbox resolves to the exact persisted Area geometry; a boundary-crossing rectangle is clipped to the official boundary.
|
||||
|
||||
## Scope implemented
|
||||
- Project CRUD
|
||||
|
||||
@@ -398,14 +398,20 @@ def select_vector_features(
|
||||
full_dataset_area = False
|
||||
preclipped_partition_filter = None
|
||||
if selection_area is not None:
|
||||
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, selection_area.id)
|
||||
if not full_dataset_area:
|
||||
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
|
||||
dataset,
|
||||
getattr(selection_area, "name", None),
|
||||
)
|
||||
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||
payload.bbox.model_dump(),
|
||||
selection_area.geometry,
|
||||
)
|
||||
full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path(
|
||||
dataset,
|
||||
selection_area.id,
|
||||
)
|
||||
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
|
||||
dataset,
|
||||
getattr(selection_area, "name", None),
|
||||
)
|
||||
selection_kwargs.update(
|
||||
selection_geometry=selection_area.geometry,
|
||||
selection_geometry=selection_geometry,
|
||||
selection_area_id=selection_area.id,
|
||||
full_dataset_area=full_dataset_area,
|
||||
preclipped_partition_filter=preclipped_partition_filter,
|
||||
@@ -418,7 +424,7 @@ def select_vector_features(
|
||||
"total_feature_count": result.get("total_feature_count"),
|
||||
}
|
||||
if selection_area is not None:
|
||||
summary_kwargs["selection_geometry"] = selection_area.geometry
|
||||
summary_kwargs["selection_geometry"] = selection_geometry
|
||||
summary_kwargs["full_dataset_area"] = full_dataset_area
|
||||
summary_kwargs["preclipped_partition_filter"] = preclipped_partition_filter
|
||||
result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs)
|
||||
@@ -437,10 +443,23 @@ def derive_vector_selection_dataset(
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
if dataset.dataset_type not in {"vector", "geojson"}:
|
||||
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
|
||||
selection_geometry = None
|
||||
selection_area_id = None
|
||||
if payload.area_id is not None:
|
||||
selection_area = db.get(Area, payload.area_id)
|
||||
if selection_area is None or selection_area.project_id != project_id:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||
payload.bbox.model_dump(),
|
||||
selection_area.geometry,
|
||||
)
|
||||
selection_area_id = selection_area.id
|
||||
derived = VectorOperationsService.derive_selection_dataset(
|
||||
db=db,
|
||||
dataset_id=dataset_id,
|
||||
bbox=payload.bbox.model_dump(),
|
||||
selection_geometry=selection_geometry,
|
||||
selection_area_id=selection_area_id,
|
||||
limit=payload.limit,
|
||||
output_name=payload.output_name,
|
||||
)
|
||||
|
||||
@@ -227,14 +227,20 @@ class ExportService:
|
||||
area = db.get(Area, area_id)
|
||||
if not area or area.project_id != dataset.project_id:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id)
|
||||
preclipped_partition_filter = (
|
||||
None
|
||||
if full_dataset_area
|
||||
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
|
||||
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||
bbox,
|
||||
area.geometry,
|
||||
)
|
||||
full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path(
|
||||
dataset,
|
||||
area.id,
|
||||
)
|
||||
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
|
||||
dataset,
|
||||
getattr(area, "name", None),
|
||||
)
|
||||
selection_kwargs.update(
|
||||
selection_geometry=area.geometry,
|
||||
selection_geometry=selection_geometry,
|
||||
selection_area_id=area.id,
|
||||
full_dataset_area=full_dataset_area,
|
||||
preclipped_partition_filter=preclipped_partition_filter,
|
||||
|
||||
@@ -111,6 +111,13 @@ class TemporalAnalysisService:
|
||||
|
||||
bbox = payload.bbox.model_dump()
|
||||
selection_area = TemporalAnalysisService._get_selection_area(db, project_id, payload.area_id)
|
||||
selection_geometry = None
|
||||
selection_covers_full_area = False
|
||||
if selection_area is not None:
|
||||
selection_geometry, selection_covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||
bbox,
|
||||
selection_area.geometry,
|
||||
)
|
||||
summaries: dict[UUID, dict[str, Any]] = {}
|
||||
|
||||
def summarize(dataset: Dataset) -> dict[str, Any]:
|
||||
@@ -119,10 +126,13 @@ class TemporalAnalysisService:
|
||||
return cached
|
||||
kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
|
||||
if selection_area is not None:
|
||||
kwargs["selection_geometry"] = selection_area.geometry
|
||||
kwargs["full_dataset_area"] = VectorFeatureService.can_use_full_area_fast_path(
|
||||
dataset,
|
||||
selection_area.id,
|
||||
kwargs["selection_geometry"] = selection_geometry
|
||||
kwargs["full_dataset_area"] = (
|
||||
selection_covers_full_area
|
||||
and VectorFeatureService.can_use_full_area_fast_path(
|
||||
dataset,
|
||||
selection_area.id,
|
||||
)
|
||||
)
|
||||
summary = VectorFeatureService.summarize_features_by_bbox(db, **kwargs)
|
||||
summaries[dataset.id] = summary
|
||||
@@ -154,14 +164,16 @@ class TemporalAnalysisService:
|
||||
later=later,
|
||||
bbox=bbox,
|
||||
preview_limit=payload.preview_limit,
|
||||
selection_geometry=selection_area.geometry if selection_area is not None else None,
|
||||
selection_geometry=selection_geometry,
|
||||
earlier_full_dataset_area=(
|
||||
VectorFeatureService.can_use_full_area_fast_path(earlier, selection_area.id)
|
||||
selection_covers_full_area
|
||||
and VectorFeatureService.can_use_full_area_fast_path(earlier, selection_area.id)
|
||||
if selection_area is not None
|
||||
else False
|
||||
),
|
||||
later_full_dataset_area=(
|
||||
VectorFeatureService.can_use_full_area_fast_path(later, selection_area.id)
|
||||
selection_covers_full_area
|
||||
and VectorFeatureService.can_use_full_area_fast_path(later, selection_area.id)
|
||||
if selection_area is not None
|
||||
else False
|
||||
),
|
||||
|
||||
@@ -8,8 +8,7 @@ from uuid import UUID
|
||||
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
|
||||
from geoalchemy2.shape import from_shape
|
||||
from geoalchemy2.shape import to_shape
|
||||
from shapely.geometry import mapping
|
||||
from shapely.geometry import shape
|
||||
from shapely.geometry import box, mapping, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
from shapely.validation import make_valid
|
||||
from sqlalchemy import Float, cast, func
|
||||
@@ -189,6 +188,27 @@ class VectorFeatureService:
|
||||
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
||||
return isinstance(source_metadata.get("selection_aggregation"), dict) or VectorFeatureService._dataset_theme(dataset) is not None
|
||||
|
||||
@staticmethod
|
||||
def constrain_bbox_to_area(
|
||||
bbox: dict[str, Any],
|
||||
area_geometry: Any,
|
||||
) -> tuple[Any, bool]:
|
||||
bbox_geometry = box(
|
||||
float(bbox["min_x"]),
|
||||
float(bbox["min_y"]),
|
||||
float(bbox["max_x"]),
|
||||
float(bbox["max_y"]),
|
||||
)
|
||||
area_shape = to_shape(area_geometry)
|
||||
constrained_geometry = bbox_geometry.intersection(area_shape)
|
||||
if constrained_geometry.is_empty or constrained_geometry.area <= 0:
|
||||
raise AppError(
|
||||
code="VECTOR_SELECTION_OUTSIDE_AREA",
|
||||
message="Selection does not overlap the selected work area",
|
||||
status_code=422,
|
||||
)
|
||||
return from_shape(constrained_geometry, srid=4326), constrained_geometry.equals(area_shape)
|
||||
|
||||
@staticmethod
|
||||
def can_use_full_area_fast_path(dataset: Dataset, selection_area_id: UUID | None) -> bool:
|
||||
if selection_area_id is None or dataset.area_id != selection_area_id:
|
||||
@@ -343,7 +363,7 @@ class VectorFeatureService:
|
||||
if preclipped_partition_filter is not None:
|
||||
partition_property, partition_value = preclipped_partition_filter
|
||||
query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value)
|
||||
elif not full_dataset_area:
|
||||
if not full_dataset_area:
|
||||
query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape))
|
||||
if hasattr(query, "count"):
|
||||
total_feature_count = int(query.count())
|
||||
@@ -413,9 +433,9 @@ class VectorFeatureService:
|
||||
selection_filter += (
|
||||
VectorFeature.properties_json.op("->>")(partition_property) == partition_value,
|
||||
)
|
||||
elif not full_dataset_area:
|
||||
if not full_dataset_area:
|
||||
selection_filter += (ST_Intersects(VectorFeature.geometry, selection_shape),)
|
||||
selection_is_preclipped = full_dataset_area or preclipped_partition_filter is not None
|
||||
selection_is_preclipped = full_dataset_area
|
||||
feature_count = total_feature_count
|
||||
if feature_count is None:
|
||||
feature_count = int(db.query(func.count(VectorFeature.id)).filter(*selection_filter).scalar() or 0)
|
||||
|
||||
@@ -284,6 +284,8 @@ class VectorOperationsService:
|
||||
db: Session,
|
||||
dataset_id: uuid.UUID,
|
||||
bbox: dict[str, Any],
|
||||
selection_geometry: Any | None = None,
|
||||
selection_area_id: uuid.UUID | None = None,
|
||||
limit: int = 250,
|
||||
output_name: str | None = None,
|
||||
) -> DatasetCreateResponse:
|
||||
@@ -292,7 +294,14 @@ class VectorOperationsService:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||
VectorOperationsService._require_vector_dataset(source_dataset)
|
||||
|
||||
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
|
||||
selection = VectorFeatureService.select_features_by_bbox(
|
||||
db,
|
||||
dataset_id=dataset_id,
|
||||
bbox=bbox,
|
||||
selection_geometry=selection_geometry,
|
||||
selection_area_id=selection_area_id,
|
||||
limit=limit,
|
||||
)
|
||||
if selection["feature_count"] <= 0:
|
||||
raise AppError(
|
||||
code="VECTOR_OPERATION_EMPTY_RESULT",
|
||||
@@ -316,6 +325,7 @@ class VectorOperationsService:
|
||||
source_name="map_selection",
|
||||
source_metadata={
|
||||
"selection_bbox": selection["selection_bbox"],
|
||||
"selection_area_id": selection.get("selection_area_id"),
|
||||
"feature_count": selection["feature_count"],
|
||||
"limit": selection["limit"],
|
||||
"truncated": selection["truncated"],
|
||||
@@ -326,9 +336,11 @@ class VectorOperationsService:
|
||||
"source_dataset_id": str(dataset_id),
|
||||
"source_table": "vector_features",
|
||||
"selection_bbox": selection["selection_bbox"],
|
||||
"selection_area_id": selection.get("selection_area_id"),
|
||||
},
|
||||
metadata_extra={
|
||||
"selection_bbox": selection["selection_bbox"],
|
||||
"selection_area_id": selection.get("selection_area_id"),
|
||||
"source_feature_count": selection["feature_count"],
|
||||
"selection_limit": selection["limit"],
|
||||
"selection_truncated": selection["truncated"],
|
||||
|
||||
@@ -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