Files
geointel/backend/tests/test_change_detection_area_selection.py
T
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

129 lines
4.4 KiB
Python

"""Change detection must answer the question the operator actually asked.
Every other analysis in the workbench is bounded by the drawn selection.
Change detection was not: it compared two datasets in full, loaded every
feature of both into Python, and — with ``include_unchanged`` defaulting to
true — returned a FeatureCollection containing both datasets entire. For a
regional building layer that is the wrong answer to "what changed here" and a
response no browser should be asked to hold.
"""
from __future__ import annotations
import pytest
from shapely.geometry import box
from app.core.errors import AppError
from app.services.change_detection_service import ChangeDetectionService
def _feature(feature_id: str, geometry):
return {"feature_id": feature_id, "properties": {}, "geometry": geometry}
INSIDE = box(0.0, 0.0, 1.0, 1.0)
OUTSIDE = box(50.0, 50.0, 51.0, 51.0)
SELECTION = box(-1.0, -1.0, 2.0, 2.0)
def test_features_outside_the_selection_are_not_compared() -> None:
kept = ChangeDetectionService.restrict_to_selection(
[_feature("in", INSIDE), _feature("out", OUTSIDE)],
SELECTION,
)
assert [item["feature_id"] for item in kept] == ["in"]
def test_a_feature_crossing_the_selection_edge_is_kept_and_flagged() -> None:
crossing = box(1.5, 1.5, 3.0, 3.0)
kept = ChangeDetectionService.restrict_to_selection(
[_feature("crossing", crossing)],
SELECTION,
)
assert len(kept) == 1
assert kept[0]["partially_covered"] is True
# The geometry is not clipped: a change class describes a whole object, and
# comparing a clipped 2020 footprint with an unclipped 2024 one would
# invent change at the selection edge.
assert kept[0]["geometry"].equals(crossing)
def test_a_feature_wholly_inside_is_not_flagged() -> None:
kept = ChangeDetectionService.restrict_to_selection([_feature("in", INSIDE)], SELECTION)
assert kept[0]["partially_covered"] is False
def test_no_selection_leaves_the_population_untouched() -> None:
features = [_feature("in", INSIDE), _feature("out", OUTSIDE)]
assert ChangeDetectionService.restrict_to_selection(features, None) == features
def test_an_empty_intersection_is_an_explicit_error_not_a_silent_zero() -> None:
with pytest.raises(AppError) as exc_info:
ChangeDetectionService.restrict_to_selection([_feature("out", OUTSIDE)], SELECTION, label="Source")
assert exc_info.value.code == "CHANGE_DETECTION_SELECTION_EMPTY"
assert "Source" in exc_info.value.message
def test_the_preview_is_capped_while_the_counts_stay_complete() -> None:
features = [
{
"change_type": "added" if index % 2 else "removed",
"geometry": box(index, 0, index + 1, 1),
"source_feature_id": None,
"target_feature_id": f"t{index}",
"iou": None,
"properties": {},
}
for index in range(250)
]
preview, truncated = ChangeDetectionService.limit_preview(features, limit=100)
assert len(preview) == 100
assert truncated is True
def test_a_short_result_is_not_reported_as_truncated() -> None:
features = [
{
"change_type": "added",
"geometry": box(0, 0, 1, 1),
"source_feature_id": None,
"target_feature_id": "t",
"iou": None,
"properties": {},
}
]
preview, truncated = ChangeDetectionService.limit_preview(features, limit=100)
assert len(preview) == 1
assert truncated is False
def test_the_preview_prefers_changes_over_unchanged_features() -> None:
"""A cap must not spend its budget on the least interesting class."""
features = [
{"change_type": "unchanged", "geometry": box(index, 0, index + 1, 1), "source_feature_id": f"s{index}",
"target_feature_id": f"t{index}", "iou": 1.0, "properties": {}}
for index in range(100)
] + [
{"change_type": "added", "geometry": box(0, 5, 1, 6), "source_feature_id": None,
"target_feature_id": "new", "iou": None, "properties": {}},
{"change_type": "modified", "geometry": box(0, 7, 1, 8), "source_feature_id": "s",
"target_feature_id": "t", "iou": 0.6, "properties": {}},
]
preview, truncated = ChangeDetectionService.limit_preview(features, limit=3)
assert truncated is True
assert sorted(item["change_type"] for item in preview[:2]) == ["added", "modified"]