"""An extended building is a change, not a deletion plus a new building. With only added/removed/unchanged, a footprint that grew by an annexe drops below the IoU threshold and is reported twice: once as removed and once as added. That hides exactly the category a change-detection product exists to show, and inflates both counts. """ from __future__ import annotations import pytest from shapely.geometry import box from app.services.change_detection_service import ChangeDetectionService def _feature(feature_id: str, geometry): return {"feature_id": feature_id, "properties": {}, "geometry": geometry} def _classify(source, target, *, iou_threshold=0.8, modified_threshold=0.3): return ChangeDetectionService._classify_features( source, target, iou_threshold=iou_threshold, modified_threshold=modified_threshold, ) def test_an_extended_footprint_is_reported_as_modified() -> None: source = [_feature("b1", box(0, 0, 10, 10))] target = [_feature("b1-new", box(0, 0, 10, 14))] # IoU 100/140 = 0.71 result = _classify(source, target) assert [item["change_type"] for item in result] == ["modified"] assert result[0]["source_feature_id"] == "b1" assert result[0]["target_feature_id"] == "b1-new" assert result[0]["iou"] == pytest.approx(100 / 140) def test_a_nearly_identical_footprint_is_unchanged() -> None: source = [_feature("b1", box(0, 0, 10, 10))] target = [_feature("b1", box(0, 0, 10, 10.2))] result = _classify(source, target) assert [item["change_type"] for item in result] == ["unchanged"] def test_a_genuinely_new_building_stays_added() -> None: source = [_feature("b1", box(0, 0, 10, 10))] target = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))] result = _classify(source, target) assert sorted(item["change_type"] for item in result) == ["added", "unchanged"] def test_a_demolished_building_stays_removed() -> None: source = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))] target = [_feature("b1", box(0, 0, 10, 10))] result = _classify(source, target) assert sorted(item["change_type"] for item in result) == ["removed", "unchanged"] def test_barely_overlapping_footprints_are_not_called_modified() -> None: """Below the modified floor the two are separate objects, not one changed.""" source = [_feature("b1", box(0, 0, 10, 10))] target = [_feature("b2", box(9, 9, 19, 19))] # IoU ~0.005 result = _classify(source, target) assert sorted(item["change_type"] for item in result) == ["added", "removed"] def test_each_target_is_claimed_at_most_once() -> None: source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(0, 0, 10, 12))] target = [_feature("t", box(0, 0, 10, 10))] result = _classify(source, target) claimed = [item for item in result if item["target_feature_id"] == "t"] assert len(claimed) == 1 def test_classification_is_independent_of_input_order() -> None: source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(30, 30, 40, 40))] target = [_feature("a2", box(0, 0, 10, 14)), _feature("c", box(70, 70, 80, 80))] forward = _classify(source, target) reverse = _classify(list(reversed(source)), list(reversed(target))) def signature(items): return sorted( (item["change_type"], item["source_feature_id"], item["target_feature_id"]) for item in items ) assert signature(forward) == signature(reverse) def test_large_populations_do_not_use_a_full_cross_product() -> None: """A spatial index keeps a city-sized comparison tractable.""" source = [_feature(f"s{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)] target = [_feature(f"t{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)] result = _classify(source, target) assert all(item["change_type"] == "unchanged" for item in result) assert len(result) == 400