"""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"]