from __future__ import annotations from datetime import datetime, timezone from uuid import uuid4 from fastapi.testclient import TestClient from geoalchemy2.shape import from_shape from shapely.geometry import box from app.db.session import get_db from app.main import app from app.models import Dataset, Job, VectorFeature from app.schemas.analysis import ChangeDetectionSummary from app.services.change_detection_service import ChangeDetectionService class FakeQuery: def __init__(self, rows): self.rows = list(rows) def filter(self, *criteria): for criterion in criteria: left = getattr(criterion, "left", None) right = getattr(criterion, "right", None) operator = getattr(criterion, "operator", None) name = getattr(left, "name", None) value = getattr(right, "value", right) if name and operator and operator.__name__ == "eq": self.rows = [row for row in self.rows if getattr(row, name) == value] return self def all(self): return list(self.rows) class FakeSession: def __init__(self, objects=None, query_rows=None) -> None: self.objects = objects or {} self.query_rows = query_rows or {} self.added = [] self.commits = 0 self.refreshes = [] def get(self, model, item_id): return self.objects.get((model, item_id)) def query(self, model): return FakeQuery(self.query_rows.get(model, [])) def add(self, item) -> None: self.added.append(item) if getattr(item, "id", None) is not None: self.objects[(item.__class__, item.id)] = item def commit(self) -> None: self.commits += 1 def refresh(self, item) -> None: self.refreshes.append(item) def _dataset(dataset_id, project_id, name): return Dataset( id=dataset_id, project_id=project_id, name=name, dataset_type="vector", source="manual", dataset_role="source", ) def _feature(dataset_id, source_feature_id, geometry): return VectorFeature( id=uuid4(), dataset_id=dataset_id, source_feature_id=source_feature_id, geometry=from_shape(geometry, srid=4326), properties_json={"source_feature_id": source_feature_id}, ) def test_change_detection_compares_persisted_vector_features() -> None: project_id = uuid4() source_dataset_id = uuid4() target_dataset_id = uuid4() source_dataset = _dataset(source_dataset_id, project_id, "before.geojson") target_dataset = _dataset(target_dataset_id, project_id, "after.geojson") rows = [ _feature(source_dataset_id, "source-unchanged", box(0, 0, 1, 1)), _feature(source_dataset_id, "source-removed", box(10, 10, 11, 11)), _feature(target_dataset_id, "target-unchanged", box(0, 0, 1, 1)), _feature(target_dataset_id, "target-added", box(20, 20, 21, 21)), ] db = FakeSession( objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset}, query_rows={VectorFeature: rows}, ) result = ChangeDetectionService.compare_vector_datasets( db=db, project_id=project_id, source_dataset_id=source_dataset_id, target_dataset_id=target_dataset_id, iou_threshold=0.8, ) change_types = [feature["properties"]["change_type"] for feature in result.geojson["features"]] assert result.source_feature_count == 2 assert result.target_feature_count == 2 assert result.added_count == 1 assert result.removed_count == 1 assert result.unchanged_count == 1 assert sorted(change_types) == ["added", "removed", "unchanged"] assert result.warnings == [] def test_change_detection_endpoint_returns_canonical_envelope(monkeypatch) -> None: project_id = uuid4() source_dataset_id = uuid4() target_dataset_id = uuid4() source_dataset = _dataset(source_dataset_id, project_id, "before.geojson") target_dataset = _dataset(target_dataset_id, project_id, "after.geojson") db = FakeSession(objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset}) summary = ChangeDetectionSummary( source_dataset_id=source_dataset_id, target_dataset_id=target_dataset_id, source_feature_count=1, target_feature_count=1, added_count=0, removed_count=0, unchanged_count=1, iou_threshold=0.8, warnings=[], generated_at=datetime.now(timezone.utc), geojson={"type": "FeatureCollection", "features": []}, ) monkeypatch.setattr( "app.api.routes.analysis.ChangeDetectionService.compare_vector_datasets", lambda **_kwargs: summary, ) app.dependency_overrides[get_db] = lambda: db try: response = TestClient(app).post( "/api/v1/analysis/change-detection", json={ "source_dataset_id": str(source_dataset_id), "target_dataset_id": str(target_dataset_id), "iou_threshold": 0.8, "include_unchanged": True, }, ) finally: app.dependency_overrides.pop(get_db, None) assert response.status_code == 200 payload = response.json() assert set(payload) == {"data"} assert payload["data"]["job_type"] == "analysis.change-detection" assert payload["data"]["status"] == "success" assert payload["data"]["result_json"]["unchanged_count"] == 1 assert any(isinstance(item, Job) for item in db.added)