Initial GeoIntel V1 foundation
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-16 23:36:32 +02:00
commit 6ea3586a3e
605 changed files with 45284 additions and 0 deletions
View File
@@ -0,0 +1,10 @@
from pathlib import Path
def test_alembic_logging_formatter_uses_runtime_interpolation_tokens() -> None:
config = Path(__file__).resolve().parents[1] / "alembic.ini"
content = config.read_text(encoding="utf-8")
assert "format = %(levelname)-5.5s [%(name)s] %(message)s" in content
assert "%%(levelname)" not in content
assert "%%(message)" not in content
+173
View File
@@ -0,0 +1,173 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_backend_dockerfile_copies_package_sources_before_pip_install() -> None:
dockerfile = ROOT / "backend" / "Dockerfile"
lines = dockerfile.read_text(encoding="utf-8").splitlines()
pip_install_index = lines.index('RUN pip install --no-cache-dir ".[gis]"')
preceding = "\n".join(lines[:pip_install_index])
assert "COPY pyproject.toml README.md /app/" in preceding
assert "COPY app /app/app" in preceding
def test_backend_dockerfile_installs_approved_gis_runtime_stack() -> None:
dockerfile = (ROOT / "backend" / "Dockerfile").read_text(encoding="utf-8")
assert 'RUN pip install --no-cache-dir ".[gis]"' in dockerfile
assert "RUN python scripts/gis_import_smoke.py" in dockerfile
assert "gdal-bin" in dockerfile
assert "libgdal-dev" in dockerfile
assert "libgeos-dev" in dockerfile
assert "libproj-dev" in dockerfile
assert "proj-bin" in dockerfile
def test_backend_pyproject_exposes_gis_optional_dependency_group() -> None:
pyproject = (ROOT / "backend" / "pyproject.toml").read_text(encoding="utf-8")
assert "gis = [" in pyproject
assert '"rasterio>=1.4.3"' in pyproject
assert '"geopandas>=1.0.1"' in pyproject
assert '"pyogrio>=0.10.0"' in pyproject
assert '"ultralytics>=8.3,<9"' not in pyproject.split("gis = [", 1)[1].split("]", 1)[0]
def test_compose_does_not_require_missing_root_env_file() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "env_file:" not in compose
assert "DATABASE_URL: postgresql+psycopg://geointel:geointel@db:5432/geointel" in compose
def test_compose_exposes_frontend_on_host_port_1202_with_cors_origin() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
assert '"1202:80"' in compose
assert "CORS_ORIGINS: http://localhost:1202,http://127.0.0.1:1202" in compose
assert "http://localhost:1202" in env_example
assert "http://127.0.0.1:1202" in env_example
def test_env_example_uses_runtime_env_names_read_by_backend_and_frontend() -> None:
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
assert "YOLO_ENABLED=false" in env_example
assert "YOLO_MODEL_PATH=" in env_example
assert "YOLO_MAX_TILES=100" in env_example
assert "ENABLE_YOLO" not in env_example
assert "ENABLE_SAM" not in env_example
assert "VITE_API_BASE_URL=" in env_example
assert "VITE_API_PROXY_TARGET=http://localhost:8000" in env_example
def test_frontend_uses_same_origin_api_proxy_by_default() -> None:
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
nginx_config = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8")
dockerfile = (ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8")
assert '?? ""' in api_client
assert "http://localhost:8000" not in api_client
assert "FROM nginx:" in dockerfile
assert "COPY --from=build /app/dist /usr/share/nginx/html" in dockerfile
assert "location /api/" in nginx_config
assert "proxy_pass http://backend:8000/api/" in nginx_config
assert "location = /health" in nginx_config
assert "try_files $uri $uri/ /index.html" in nginx_config
def test_compose_does_not_publish_postgis_on_default_host_port() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert '"5432:5432"' not in compose
def test_compose_waits_for_healthy_database_and_applies_migrations() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "pg_isready -U geointel -d geointel" in compose
assert "condition: service_healthy" in compose
assert "sh /app/docker_start.sh" in compose
def test_compose_has_backend_and_frontend_healthchecks() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "http://127.0.0.1:8000/health" in compose
assert "urllib.request.urlopen" in compose
assert "http://127.0.0.1/health" in compose
assert "wget -q -O -" in compose
assert "start_period: 30s" in compose
assert "start_period: 10s" in compose
def test_frontend_waits_for_healthy_backend_in_compose() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
frontend_section = compose.split(" frontend:", 1)[1]
assert "backend:" in frontend_section
assert "condition: service_healthy" in frontend_section
def test_backend_docker_start_script_waits_for_sql_connection_before_migrations() -> None:
script = (ROOT / "backend" / "docker_start.sh").read_text(encoding="utf-8")
assert "Waiting for database connection" in script
assert "create_engine(settings.database_url" in script
assert "SELECT 1" in script
assert "python -m alembic upgrade head" in script
assert "uvicorn app.main:app --host 0.0.0.0 --port 8000" in script
def test_docker_build_contexts_exclude_vendor_build_and_cache_outputs() -> None:
required_patterns = {
"node_modules",
"dist",
"__pycache__",
"*.pyc",
".pytest_cache",
}
for relative_path in ("backend/.dockerignore", "frontend/.dockerignore"):
content = (ROOT / relative_path).read_text(encoding="utf-8")
for pattern in required_patterns:
assert pattern in content
def test_browser_runtime_verification_script_detects_proxy_contract() -> None:
script = (ROOT / "scripts" / "verify_browser_runtime.sh").read_text(encoding="utf-8")
assert "/api/v1/projects" in script
assert "<!doctype html" in script
assert "canonical GeoIntel envelope" in script
assert '"status":"ok"' in script
def test_gis_runtime_verification_script_detects_required_capabilities() -> None:
script = (ROOT / "scripts" / "verify_gis_runtime.sh").read_text(encoding="utf-8")
assert "/api/v1/system/capabilities" in script
assert '"postgis":true' in script
assert '"rasterio":true' in script
assert '"geopandas":true' in script
assert "<!doctype html" in script
def test_gis_import_smoke_script_checks_runtime_imports() -> None:
docker_script = (ROOT / "backend" / "scripts" / "gis_import_smoke.py").read_text(encoding="utf-8")
root_wrapper = (ROOT / "scripts" / "gis_import_smoke.py").read_text(encoding="utf-8")
assert 'REQUIRED_MODULES = ("rasterio", "geopandas", "pyogrio")' in docker_script
assert "importlib.import_module" in docker_script
assert '"gis_imports"' in docker_script
assert 'ROOT / "backend" / "scripts"' in root_wrapper
assert "from gis_import_smoke import main" in root_wrapper
def test_backend_docker_context_contains_gis_import_smoke_script() -> None:
assert (ROOT / "backend" / "scripts" / "gis_import_smoke.py").exists()
@@ -0,0 +1,170 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from app.core.errors import AppError
from app.services import geojson_service
from app.services.dataset_service import DatasetService
def test_parse_geojson_payload_extracts_metadata() -> None:
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.5, 51.3],
},
}
],
}
metadata = geojson_service.parse_geojson_payload(payload)
assert metadata["feature_count"] == 1
assert metadata["feature_geometry_count"] == 1
assert metadata["bounds_json"] == {
"min_x": 4.5,
"min_y": 51.3,
"max_x": 4.5,
"max_y": 51.3,
}
assert metadata["geometry_types"] == ["Point"]
def test_parse_geojson_payload_rejects_non_feature_collection() -> None:
payload = {"type": "Feature", "features": []}
try:
geojson_service.parse_geojson_payload(payload)
except ValueError as exc:
assert "FeatureCollection" in str(exc)
else:
raise AssertionError("Invalid GeoJSON should raise ValueError")
def test_get_dataset_geojson_reads_stored_payload(tmp_path, monkeypatch) -> None:
file_path = tmp_path / "dataset.geojson"
file_path.write_text(
json.dumps({"type": "FeatureCollection", "features": []}),
encoding="utf-8",
)
dataset = SimpleNamespace(dataset_type="vector", storage_path=str(file_path))
monkeypatch.setattr(DatasetService, "get_dataset", lambda _db, _id: dataset)
payload = DatasetService.get_dataset_geojson(Path("."), uuid4())
assert payload["type"] == "FeatureCollection"
def test_get_dataset_geojson_rejects_invalid_stored_json(tmp_path, monkeypatch) -> None:
file_path = tmp_path / "invalid.geojson"
file_path.write_text("not-json", encoding="utf-8")
dataset = SimpleNamespace(dataset_type="vector", storage_path=str(file_path))
monkeypatch.setattr(DatasetService, "get_dataset", lambda _db, _id: dataset)
try:
DatasetService.get_dataset_geojson(Path("."), uuid4())
except AppError as exc:
assert exc.code == "INVALID_GEOJSON"
else:
raise AssertionError("Invalid stored payload should raise AppError")
def test_parse_geojson_payload_returns_vector_metadata() -> None:
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[4.3, 51.2],
[4.4, 51.2],
[4.4, 51.3],
[4.3, 51.3],
[4.3, 51.2],
]
],
},
}
],
"crs": {"type": "name", "properties": {"name": "EPSG:31370"}},
}
metadata = geojson_service.parse_geojson_payload(payload)
assert metadata["feature_count"] == 1
assert metadata["feature_geometry_count"] == 1
assert metadata["geometry_types"] == ["Polygon"]
assert metadata["bounds_json"] == {
"min_x": 4.3,
"min_y": 51.2,
"max_x": 4.4,
"max_y": 51.3,
}
assert metadata["crs"] == "EPSG:31370"
assert metadata["approximate_area_m2"] is not None
assert metadata["approximate_area_m2"] >= 0.0
def test_parse_geojson_payload_rejects_invalid_geometry() -> None:
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": "invalid",
},
}
],
}
try:
geojson_service.parse_geojson_payload(payload)
except ValueError as exc:
assert "Invalid feature geometry" in str(exc)
else:
raise AssertionError("Invalid geometry should raise ValueError")
def test_get_dataset_geojson_accepts_legacy_geojson_type(tmp_path, monkeypatch) -> None:
file_path = tmp_path / "legacy.geojson"
file_path.write_text(
json.dumps({"type": "FeatureCollection", "features": []}),
encoding="utf-8",
)
dataset = SimpleNamespace(dataset_type="geojson", storage_path=str(file_path))
monkeypatch.setattr(DatasetService, "get_dataset", lambda _db, _id: dataset)
payload = DatasetService.get_dataset_geojson(Path("."), uuid4())
assert payload["type"] == "FeatureCollection"
def test_vector_summary_supports_legacy_geojson_type(monkeypatch) -> None:
dataset = SimpleNamespace(
dataset_type="geojson",
metadata_json={
"feature_count": 7,
"geometry_types": ["Point"],
"bounds_json": {"min_x": 0.0, "min_y": 0.0, "max_x": 1.0, "max_y": 1.0},
},
storage_path="",
)
monkeypatch.setattr(DatasetService, "get_dataset", lambda _db, _id: dataset)
summary = DatasetService.vector_summary(Path("."), uuid4())
assert summary["feature_count"] == 7
assert summary["geometry_types"] == ["Point"]
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.api.routes import health
from app.main import app
def test_health_endpoint_returns_status_payload() -> None:
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
payload = response.json()
assert payload["status"] in {"ok", "degraded"}
assert payload["service"] == "geointel-backend"
assert payload["version"] == "0.1.0"
def test_system_capabilities_reports_gis_dependency_flags(monkeypatch) -> None:
monkeypatch.setattr(
health,
"_dependency_enabled",
lambda module_name: module_name in {"rasterio", "geopandas"},
)
client = TestClient(app)
response = client.get("/api/v1/system/capabilities")
assert response.status_code == 200
payload = response.json()
assert payload["data"]["rasterio"] is True
assert payload["data"]["geopandas"] is True
@@ -0,0 +1,24 @@
from pathlib import Path
def test_live_migration_smoke_checks_postgis_after_migrations() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "live_migration_smoke.sh"
content = script.read_text(encoding="utf-8")
upgrade_index = content.index("-m alembic upgrade head")
postgis_index = content.index("PostGIS_Version()")
assert upgrade_index < postgis_index
def test_live_migration_smoke_checks_required_runtime_schema_objects() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "live_migration_smoke.sh"
content = script.read_text(encoding="utf-8")
assert "to_regclass(:object_name)" in content
assert '"public.projects"' in content
assert '"public.datasets"' in content
assert '"public.vector_features"' in content
assert '"public.detections"' in content
assert '"public.segmentations"' in content
assert '"public.ix_segmentations_geometry"' in content
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from app.models import Area, Dataset
from app.services.qa_service import QaService
class FakeSession:
def __init__(self, datasets=None, areas=None):
self.datasets = {item.id: item for item in (datasets or [])}
self.areas = {item.id: item for item in (areas or [])}
def get(self, model, item_id):
if model.__name__ == "Dataset":
return self.datasets.get(item_id)
if model.__name__ == "Area":
return self.areas.get(item_id)
return None
def _write_dataset(path: Path, coordinates: list[list[list[float]]]) -> None:
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": coordinates,
},
},
],
}
path.write_text(json.dumps(payload), encoding="utf-8")
def test_qa_compare_candidate_with_reference_returns_metrics(tmp_path) -> None:
project_id = uuid4()
candidate_id = uuid4()
reference_id = uuid4()
candidate_path = tmp_path / "candidate.geojson"
reference_path = tmp_path / "reference.geojson"
polygon = [[[4.0, 51.0], [4.1, 51.0], [4.1, 51.1], [4.0, 51.1], [4.0, 51.0]]]
_write_dataset(candidate_path, polygon)
_write_dataset(reference_path, polygon)
candidate = Dataset(
id=candidate_id,
project_id=project_id,
name="candidate.geojson",
dataset_type="vector",
source="test",
storage_path=str(candidate_path),
crs="EPSG:4326",
metadata_json={"crs_assumed": False},
)
reference = Dataset(
id=reference_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
storage_path=str(reference_path),
crs="EPSG:4326",
metadata_json={"crs_assumed": False},
)
result = QaService.compare_candidate_with_reference(
db=FakeSession([candidate, reference]),
project_id=project_id,
candidate_dataset_id=candidate_id,
reference_dataset_id=reference_id,
iou_threshold=0.5,
)
assert result.status == "ok"
assert result.matches == 1
assert result.false_positives == 0
assert result.false_negatives == 0
assert result.precision == 1.0
assert result.recall == 1.0
assert result.f1_score == 1.0
def test_dataset_reference_metadata_migration_declares_required_columns() -> None:
migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "202606120001_add_dataset_reference_metadata.py"
migration_text = migration_path.read_text(encoding="utf-8")
for column_name in (
"dataset_role",
"source_name",
"reference_layer_name",
"source_metadata",
"provenance_metadata",
"imported_at",
):
assert column_name in migration_text
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
from app.core.errors import AppError
from app.services.raster_service import extract_raster_metadata
def test_extract_raster_metadata_returns_dependency_aware_error(monkeypatch, tmp_path) -> None:
monkeypatch.setattr("app.services.raster_service._import_rasterio", lambda: (_ for _ in ()).throw(ImportError("rasterio not installed")))
file_path = tmp_path / "missing.tif"
file_path.write_bytes(b"\x00\x01\x02")
try:
extract_raster_metadata(str(file_path))
except AppError as exc:
assert exc.code == "RASTER_PROCESSING_UNAVAILABLE"
else:
raise AssertionError("Missing rasterio should raise AppError code RASTER_PROCESSING_UNAVAILABLE")
def test_extract_raster_metadata_maps_basic_profile_fields(monkeypatch, tmp_path) -> None:
file_path = tmp_path / "sample.tif"
file_path.write_bytes(b"fake")
class FakeDataset:
width = 1024
height = 768
count = 4
driver = "GTiff"
crs = "EPSG:31370"
bounds = (100.0, 200.0, 500.0, 800.0)
res = (0.25, 0.25)
dtypes = ["uint16", "uint16", "uint16", "uint16"]
nodata = -9999
class transform:
@staticmethod
def to_gdal():
return (0.25, 0.0, 100.0, 0.0, -0.25, 800.0, 0.0, 0.0, 1.0)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return None
class FakeRasterio:
class errors:
class RasterioIOError(Exception):
...
def open(self, *_):
return FakeDataset()
class FakeErrors:
RasterioIOError = FakeRasterio.errors.RasterioIOError
monkeypatch.setattr("app.services.raster_service._import_rasterio", lambda: (FakeRasterio(), FakeErrors()))
metadata = extract_raster_metadata(str(file_path))
assert metadata["driver"] == "GTiff"
assert metadata["width"] == 1024
assert metadata["height"] == 768
assert metadata["band_count"] == 4
assert metadata["crs"] == "EPSG:31370"
assert metadata["bounds"] == [100.0, 200.0, 500.0, 800.0]
assert metadata["resolution"] == [0.25, 0.25]
assert metadata["dtype"] == ["uint16", "uint16", "uint16", "uint16"]
assert metadata["nodata"] == -9999.0
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
def test_readiness_gate_treats_deprecation_warnings_as_errors() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "run_readiness_check.sh"
content = script.read_text(encoding="utf-8")
assert "-W error::DeprecationWarning" in content
def test_readiness_gate_runs_contract_smoke() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "run_readiness_check.sh"
content = script.read_text(encoding="utf-8")
assert "scripts/smoke_contracts.py" in content
def test_readiness_gate_checks_demo_export_workflow_script_syntax() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "run_readiness_check.sh"
content = script.read_text(encoding="utf-8")
assert "bash -n scripts/verify_demo_export_workflow.sh" in content
def test_demo_export_workflow_script_verifies_export_endpoints() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "verify_demo_export_workflow.sh"
content = script.read_text(encoding="utf-8")
assert "/api/v1/demo/workflow" in content
assert "/api/v1/exports/metadata" in content
assert "/api/v1/exports/report" in content
assert "/api/v1/exports/geojson" in content
assert "/download" in content
def test_pass_end_check_excludes_vendor_and_build_outputs() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "codex_pass_end_check.sh"
content = script.read_text(encoding="utf-8")
assert "--exclude-dir=node_modules" in content
assert "--exclude-dir=dist" in content
assert "--exclude-dir=__pycache__" in content
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_golden_qa_expected_metrics_are_documented() -> None:
expected_path = ROOT / "fixtures" / "golden" / "expected_qa_metrics.json"
expected = json.loads(expected_path.read_text(encoding="utf-8"))
assert expected["benchmark_id"] == "golden-buildings-partial-match-v1"
assert expected["iou_threshold"] == 0.5
assert expected["candidate_feature_count"] == 2
assert expected["reference_feature_count"] == 2
assert expected["matches"] == 1
assert expected["false_positive_count"] == 1
assert expected["false_negative_count"] == 1
assert expected["precision"] == 0.5
assert expected["recall"] == 0.5
assert expected["f1"] == 0.5
assert expected["mean_iou"] > 0.8
assert expected["tolerance"] <= 1e-9
def test_golden_qa_benchmark_command_passes_and_reports_persistence() -> None:
script = ROOT / "scripts" / "run_golden_qa_benchmark.py"
result = subprocess.run(
[sys.executable, str(script), "--json"],
cwd=ROOT,
check=True,
text=True,
capture_output=True,
)
payload = json.loads(result.stdout)
assert payload["status"] == "passed"
assert payload["benchmark_id"] == "golden-buildings-partial-match-v1"
assert payload["metrics"]["precision"] == 0.5
assert payload["metrics"]["recall"] == 0.5
assert payload["metrics"]["f1"] == 0.5
assert payload["metrics"]["false_positive_count"] == 1
assert payload["metrics"]["false_negative_count"] == 1
assert payload["persistence"]["quality_check_count"] == 1
assert payload["persistence"]["metric_count"] == 6
assert sorted(payload["persistence"]["metric_keys"]) == [
"f1",
"false_negative_count",
"false_positive_count",
"mean_iou",
"precision",
"recall",
]
@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from app.core.config import Settings
from app.services.yolo_preflight_service import YoloPreflightService
ROOT = Path(__file__).resolve().parents[2]
class AvailableAdapter:
@staticmethod
def dependencies_available() -> bool:
return True
class MissingDependencyAdapter:
@staticmethod
def dependencies_available() -> bool:
return False
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
tiles = []
for index in range(tile_count):
tile_path = tmp_path / f"tile_{index:04d}.tif"
tile_path.write_bytes(b"tile")
tiles.append(
{
"path": str(tile_path),
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"index": index,
}
)
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps({"tiles": tiles, "count": tile_count}), encoding="utf-8")
return manifest_path
def test_yolo_preflight_reports_disabled_without_loading_model(tmp_path: Path) -> None:
result = YoloPreflightService.run(
settings=Settings(yolo_enabled=False, yolo_model_path=str(tmp_path / "missing.pt")),
tile_manifest_path=str(tmp_path / "missing-manifest.json"),
yolo_adapter_class=AvailableAdapter,
)
assert result["status"] == "not_configured"
assert result["checks"]["enabled"] is False
assert result["checks"]["dependencies_available"] is None
assert result["checks"]["model_file_exists"] is None
assert result["checks"]["manifest_valid"] is None
def test_yolo_preflight_distinguishes_missing_dependencies_from_missing_model(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"weights")
result = YoloPreflightService.run(
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path)),
tile_manifest_path=str(_manifest(tmp_path)),
yolo_adapter_class=MissingDependencyAdapter,
)
assert result["status"] == "dependency_unavailable"
assert result["checks"]["dependencies_available"] is False
assert result["checks"]["model_file_exists"] is None
assert result["checks"]["manifest_valid"] is None
def test_yolo_preflight_validates_model_and_manifest_without_importing_yolo(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"weights")
manifest_path = _manifest(tmp_path, tile_count=2)
result = YoloPreflightService.run(
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
tile_manifest_path=str(manifest_path),
yolo_adapter_class=AvailableAdapter,
)
assert result["status"] == "ready"
assert result["checks"]["dependencies_available"] is True
assert result["checks"]["model_file_exists"] is True
assert result["checks"]["manifest_valid"] is True
assert result["tile_count"] == 2
assert result["will_download_models"] is False
assert result["will_run_inference"] is False
def test_yolo_preflight_script_outputs_json(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"weights")
manifest_path = _manifest(tmp_path)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "yolo_preflight.py"),
"--model-path",
str(model_path),
"--tile-manifest-path",
str(manifest_path),
"--assume-dependencies",
"--json",
],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
payload = json.loads(result.stdout)
assert payload["status"] == "ready"
assert payload["model_path"] == str(model_path)
assert payload["tile_manifest_path"] == str(manifest_path)
@@ -0,0 +1,57 @@
from __future__ import annotations
from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.schemas.demo import DemoWorkflowResponse
from app.services.demo_workflow_service import DemoWorkflowService
def test_demo_workflow_endpoint_returns_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
area_id = uuid4()
reference_dataset_id = uuid4()
candidate_dataset_id = uuid4()
quality_check_id = uuid4()
monkeypatch.setattr(
DemoWorkflowService,
"seed",
lambda _db: DemoWorkflowResponse(
project_id=project_id,
area_id=area_id,
reference_dataset_id=reference_dataset_id,
candidate_dataset_id=candidate_dataset_id,
quality_check_id=quality_check_id,
metric_count=6,
status="ready",
message="Demo workflow seeded from explicit local fixtures.",
created=True,
),
)
response = TestClient(app).post("/api/v1/demo/workflow")
assert response.status_code == 201
payload = response.json()
assert set(payload) == {"data"}
assert payload["data"]["project_id"] == str(project_id)
assert payload["data"]["reference_dataset_id"] == str(reference_dataset_id)
assert payload["data"]["candidate_dataset_id"] == str(candidate_dataset_id)
assert payload["data"]["quality_check_id"] == str(quality_check_id)
assert payload["data"]["metric_count"] == 6
assert payload["data"]["status"] == "ready"
assert payload["data"]["created"] is True
def test_demo_workflow_service_uses_explicit_golden_fixtures() -> None:
reference_path = DemoWorkflowService._fixture_path("reference_buildings.geojson")
candidate_path = DemoWorkflowService._fixture_path("predicted_buildings.geojson")
assert reference_path.exists()
assert candidate_path.exists()
assert DemoWorkflowService.PROJECT_NAME == "GeoIntel Demo - Building QA"
assert DemoWorkflowService.REFERENCE_FILENAME == "demo_reference_buildings.geojson"
assert DemoWorkflowService.CANDIDATE_FILENAME == "demo_predicted_buildings.geojson"
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.models import Metric, QualityCheck
from app.schemas.qa import QualityCheckRead
from app.services.quality_check_service import QualityCheckService
class FakeQuery:
def __init__(self, rows):
self.rows = rows
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def count(self):
return len(self.rows)
def offset(self, _offset):
return self
def limit(self, _limit):
return self
def all(self):
return self.rows
class FakeSession:
def __init__(self, quality_checks, metrics):
self.quality_checks = quality_checks
self.metrics = metrics
def query(self, model):
if model is QualityCheck:
return FakeQuery(self.quality_checks)
if model is Metric:
return FakeQuery(self.metrics)
return FakeQuery([])
def test_quality_check_service_lists_checks_with_metrics() -> None:
project_id = uuid4()
quality_check_id = uuid4()
reference_dataset_id = uuid4()
candidate_dataset_id = uuid4()
created_at = datetime.now(timezone.utc)
quality_check = QualityCheck(
id=quality_check_id,
project_id=project_id,
candidate_dataset_id=candidate_dataset_id,
reference_dataset_id=reference_dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
parameters_json={"iou_threshold": 0.5},
findings_json={"matches": 1},
created_at=created_at,
completed_at=created_at,
)
metric = Metric(
id=uuid4(),
quality_check_id=quality_check_id,
metric_key="precision",
metric_value=0.5,
metadata_json={},
created_at=created_at,
)
items, total = QualityCheckService.list_quality_checks(
FakeSession([quality_check], [metric]),
project_id=project_id,
)
assert total == 1
assert len(items) == 1
assert items[0].id == quality_check_id
assert items[0].metrics[0].metric_key == "precision"
assert items[0].metrics[0].metric_value == 0.5
def test_quality_checks_endpoint_returns_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
quality_check_id = uuid4()
reference_dataset_id = uuid4()
monkeypatch.setattr(
QualityCheckService,
"list_quality_checks",
lambda *_args, **_kwargs: (
[
QualityCheckRead(
id=quality_check_id,
project_id=project_id,
reference_dataset_id=reference_dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
metrics=[],
)
],
1,
),
)
response = TestClient(app).get(f"/api/v1/projects/{project_id}/quality-checks")
assert response.status_code == 200
payload = response.json()
assert set(payload) == {"data"}
assert payload["data"]["total"] == 1
assert payload["data"]["items"][0]["id"] == str(quality_check_id)
assert payload["data"]["items"][0]["check_type"] == "demo_candidate_vs_reference"
@@ -0,0 +1,302 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from app.core.errors import AppError
from app.main import app
from app.models import Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
class FakeQuery:
def __init__(self, rows):
self.rows = rows
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def offset(self, _offset):
return self
def limit(self, _limit):
return self
def count(self):
return len(self.rows)
def all(self):
return self.rows
class FakeSession:
def __init__(self, rows):
self.rows = rows
self.added = []
def get(self, model, row_id):
row = self.rows.get((model, row_id))
if row is not None:
return row
for item in self.added:
if isinstance(item, model) and item.id == row_id:
return item
return None
def query(self, model):
rows = [row for (row_model, _row_id), row in self.rows.items() if row_model is model]
rows.extend([row for row in self.added if isinstance(row, model)])
return FakeQuery(rows)
def add(self, row):
self.added.append(row)
def commit(self):
return None
def refresh(self, row):
return row
def test_dataset_geojson_export_persists_export_and_writes_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
dataset_path = tmp_path / "input.geojson"
dataset_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}), encoding="utf-8")
export_path = tmp_path / "exports" / "buildings.geojson"
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="buildings.geojson",
dataset_type="vector",
source="fixture",
storage_path=str(dataset_path),
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_dataset_geojson(db, dataset_id, name="buildings")
exports = [item for item in db.added if isinstance(item, Export)]
assert len(exports) == 1
assert response.export_id == exports[0].id
assert response.export_type == "dataset_geojson"
assert response.metadata_json["feature_count"] == 0
assert json.loads(export_path.read_text(encoding="utf-8"))["type"] == "FeatureCollection"
def test_dataset_geojson_export_rejects_raster_dataset(tmp_path, monkeypatch) -> None:
dataset_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=uuid4(),
name="ortho.tif",
dataset_type="raster",
source="fixture",
storage_path=str(tmp_path / "ortho.tif"),
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "unused.geojson"))
try:
ExportService.export_dataset_geojson(db, dataset_id)
except AppError as exc:
assert exc.code == "INVALID_DATASET_TYPE"
else:
raise AssertionError("Raster datasets must not be exported as dataset GeoJSON")
def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
quality_check_id = uuid4()
project = Project(id=project_id, name="Demo", region="Kempen", status="active")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="fixture",
dataset_role="reference",
source_name="fixture",
status="ready",
metadata_json={"feature_count": 2},
)
quality_check = QualityCheck(
id=quality_check_id,
project_id=project_id,
reference_dataset_id=dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
created_at=datetime.now(timezone.utc),
)
previous_export_id = uuid4()
previous_export = Export(
id=previous_export_id,
project_id=project_id,
export_type="dataset_geojson",
storage_path="storage/exports/previous.geojson",
metadata_json={"source": "dataset"},
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "metadata.json"
db = FakeSession(
{
(Project, project_id): project,
(Dataset, dataset_id): dataset,
(QualityCheck, quality_check_id): quality_check,
(Export, previous_export_id): previous_export,
}
)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_metadata(db, project_id)
payload = json.loads(export_path.read_text(encoding="utf-8"))
assert response.export_type == "project_metadata_json"
assert payload["project"]["id"] == str(project_id)
assert payload["datasets"][0]["id"] == str(dataset_id)
assert payload["quality_checks"][0]["id"] == str(quality_check_id)
assert payload["exports"][0]["id"] == str(previous_export_id)
assert response.metadata_json["export_count"] == 1
def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Demo <Kempen>", description="QA report", region="Kempen", status="active")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="fixture",
dataset_role="reference",
status="ready",
metadata_json={"feature_count": 2},
)
previous_export_id = uuid4()
previous_export = Export(
id=previous_export_id,
project_id=project_id,
export_type="project_metadata_json",
storage_path="storage/exports/metadata.json",
metadata_json={"source": "project_metadata"},
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "report.html"
db = FakeSession({(Project, project_id): project, (Dataset, dataset_id): dataset, (Export, previous_export_id): previous_export})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_report(db, project_id)
html = export_path.read_text(encoding="utf-8")
assert response.export_type == "project_report_html"
assert response.metadata_json["format"] == "html"
assert "<!doctype html>" in html
assert "Demo &lt;Kempen&gt;" in html
assert "reference.geojson" in html
assert "Export History (1)" in html
assert "project_metadata_json" in html
def test_export_content_reads_persisted_artifact(tmp_path) -> None:
export_id = uuid4()
export_path = tmp_path / "artifact.json"
export_path.write_text(json.dumps({"hello": "world"}), encoding="utf-8")
export = Export(
id=export_id,
project_id=uuid4(),
export_type="project_metadata_json",
storage_path=str(export_path),
metadata_json={},
)
db = FakeSession({(Export, export_id): export})
response = ExportService.get_export_content(db, export_id)
assert response.export_id == export_id
assert response.content == {"hello": "world"}
def test_export_download_path_rejects_missing_artifact(tmp_path) -> None:
export_id = uuid4()
export = Export(
id=export_id,
project_id=uuid4(),
export_type="dataset_geojson",
storage_path=str(tmp_path / "missing.geojson"),
metadata_json={},
)
db = FakeSession({(Export, export_id): export})
try:
ExportService.get_export_download_path(db, export_id)
except AppError as exc:
assert exc.code == "EXPORT_CONTENT_NOT_FOUND"
else:
raise AssertionError("Missing export artifacts must fail clearly")
def test_export_geojson_endpoint_returns_canonical_envelope(monkeypatch) -> None:
export_id = uuid4()
dataset_id = uuid4()
monkeypatch.setattr(
ExportService,
"export_dataset_geojson",
lambda *_args, **_kwargs: ExportCreateResponse(
export_id=export_id,
path="storage/exports/demo.geojson",
status="ready",
export_type="dataset_geojson",
metadata_json={"source": "dataset"},
),
)
response = TestClient(app).post("/api/v1/exports/geojson", json={"dataset_id": str(dataset_id)})
assert response.status_code == 200
payload = response.json()
assert set(payload) == {"data"}
assert payload["data"]["export_id"] == str(export_id)
assert payload["data"]["export_type"] == "dataset_geojson"
def test_export_download_endpoint_returns_file_response(tmp_path, monkeypatch) -> None:
export_id = uuid4()
export_path = tmp_path / "download.geojson"
export_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}), encoding="utf-8")
monkeypatch.setattr(ExportService, "get_export_download_path", lambda *_args, **_kwargs: export_path)
response = TestClient(app).get(f"/api/v1/exports/{export_id}/download")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
assert "download.geojson" in response.headers["content-disposition"]
assert response.json()["type"] == "FeatureCollection"
def test_export_download_endpoint_returns_html_media_type(tmp_path, monkeypatch) -> None:
export_id = uuid4()
export_path = tmp_path / "report.html"
export_path.write_text("<!doctype html><html><body>report</body></html>", encoding="utf-8")
monkeypatch.setattr(ExportService, "get_export_download_path", lambda *_args, **_kwargs: export_path)
response = TestClient(app).get(f"/api/v1/exports/{export_id}/download")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "report.html" in response.headers["content-disposition"]
assert "report" in response.text
@@ -0,0 +1,292 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from uuid import uuid4
import pytest
from app.api.routes.qa import compare_candidate_with_reference
from app.models import Dataset, Metric, Project, QualityCheck, VectorFeature
from app.schemas.qa import QaProviderComparisonRequest
from app.providers.registry import list_provider_capabilities
from app.services.dataset_service import DatasetService
from app.services.quality_service import QualityService
from app.services.vector_feature_service import VectorFeatureService
class FakeSession:
def __init__(self, objects=None) -> None:
self.added = []
self.objects = objects or {}
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def add(self, item) -> None:
self.added.append(item)
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
self.refreshes.append(item)
def test_vector_feature_service_persists_geojson_features_with_properties() -> None:
db = FakeSession()
dataset_id = uuid4()
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "building-1",
"properties": {"class": "building", "height": 7},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[4.0, 51.0],
[4.1, 51.0],
[4.1, 51.1],
[4.0, 51.1],
[4.0, 51.0],
]
],
},
}
],
}
persisted = VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=dataset_id,
payload=payload,
feature_class="building",
)
assert len(persisted) == 1
assert isinstance(persisted[0], VectorFeature)
assert persisted[0].dataset_id == dataset_id
assert persisted[0].feature_class == "building"
assert persisted[0].source_feature_id == "building-1"
assert persisted[0].properties_json == {"class": "building", "height": 7}
assert db.added == persisted
assert db.commits == 1
def test_dataset_upload_persists_vector_features(monkeypatch, tmp_path) -> None:
project_id = uuid4()
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Geel")})
payload = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"class": "building"},
"geometry": {
"type": "Point",
"coordinates": [4.0, 51.0],
},
}
],
}
class Upload:
filename = "reference.geojson"
content_type = "application/geo+json"
async def read(self) -> bytes:
import json
return json.dumps(payload).encode("utf-8")
storage_path = tmp_path / "reference.geojson"
storage_path.write_text("{}", encoding="utf-8")
monkeypatch.setattr(
"app.services.dataset_service.StorageService.persist_dataset_file",
lambda **_kwargs: {
"storage_path": str(storage_path),
"original_filename": "reference.geojson",
"stored_filename": "reference.geojson",
"content_type": "application/geo+json",
"size_bytes": 2,
"checksum_sha256": "0" * 64,
},
)
result = asyncio.run(
DatasetService.upload_dataset(
db=db,
project_id=project_id,
file=Upload(),
dataset_type="vector",
source="user_upload",
dataset_role="reference",
reference_layer_name="buildings",
)
)
persisted_features = [item for item in db.added if isinstance(item, VectorFeature)]
assert result.dataset_role == "reference"
assert result.source_name == "manual"
assert len(persisted_features) == 1
assert persisted_features[0].dataset_id == result.id
def test_quality_service_persists_quality_check_and_metrics() -> None:
db = FakeSession()
project_id = uuid4()
candidate_dataset_id = uuid4()
reference_dataset_id = uuid4()
job_id = uuid4()
quality_check = QualityService.persist_quality_check(
db=db,
project_id=project_id,
reference_dataset_id=reference_dataset_id,
check_type="candidate_vs_reference",
status="ok",
score=1.0,
parameters={"iou_threshold": 0.5},
findings={"matches": 1, "false_positives": 0, "false_negatives": 0},
candidate_dataset_id=candidate_dataset_id,
job_id=job_id,
metrics={
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"false_positive_count": 0,
},
)
assert isinstance(quality_check, QualityCheck)
assert quality_check.project_id == project_id
assert quality_check.job_id == job_id
assert quality_check.candidate_dataset_id == candidate_dataset_id
assert quality_check.reference_dataset_id == reference_dataset_id
assert quality_check.parameters_json == {"iou_threshold": 0.5}
assert quality_check.findings_json["matches"] == 1
persisted_metrics = [item for item in db.added if isinstance(item, Metric)]
assert [metric.metric_key for metric in persisted_metrics] == [
"precision",
"recall",
"f1",
"false_positive_count",
]
assert persisted_metrics[0].quality_check_id == quality_check.id
assert db.commits == 1
def test_dataset_role_validation_accepts_only_source_derived_reference() -> None:
assert DatasetService._normalize_dataset_role("source") == "source"
assert DatasetService._normalize_dataset_role("derived") == "derived"
assert DatasetService._normalize_dataset_role("reference") == "reference"
with pytest.raises(Exception) as exc_info:
DatasetService._normalize_dataset_role("osm")
assert getattr(exc_info.value, "code", None) == "INVALID_DATASET_ROLE"
def test_provider_capabilities_expose_sprint7a_contract() -> None:
capabilities = {capability.provider_name: capability.to_dict() for capability in list_provider_capabilities()}
assert capabilities["osm"]["supported_layers"] == ["buildings", "roads", "water", "landuse"]
assert capabilities["osm"]["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert capabilities["osm"]["supported_query_modes"] == ["area"]
assert capabilities["osm"]["status"] == "not_configured"
assert capabilities["grb"]["supported_layers"] == ["buildings", "roads", "parcels"]
assert capabilities["grb"]["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert capabilities["grb"]["supported_query_modes"] == ["area"]
assert capabilities["grb"]["status"] == "not_configured"
def test_sprint7a_migration_declares_foundation_tables_and_indexes() -> None:
migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "202606120700_sprint7a_persistence_foundation.py"
migration_text = migration_path.read_text(encoding="utf-8")
for required_text in (
"vector_features",
"quality_checks",
"metrics",
"ix_vector_features_geometry",
'postgresql_using="gist"',
"ix_quality_checks_project_id",
"ix_metrics_quality_check_id",
):
assert required_text in migration_text
def test_qa_route_persists_quality_check_domain_record(monkeypatch) -> None:
project_id = uuid4()
candidate_dataset_id = uuid4()
reference_dataset_id = uuid4()
job_id = uuid4()
candidate_dataset = Dataset(
id=candidate_dataset_id,
project_id=project_id,
name="candidate.geojson",
dataset_type="vector",
source="test",
)
db = FakeSession(objects={(Dataset, candidate_dataset_id): candidate_dataset})
def run_sync_job(**kwargs):
result = kwargs["operation"]()
return {
"id": str(job_id),
"project_id": str(project_id),
"status": "success",
"result_json": result,
}
monkeypatch.setattr("app.api.routes.qa.JobService.run_sync_job", run_sync_job)
monkeypatch.setattr(
"app.api.routes.qa.QaService.compare_candidate_with_reference",
lambda **_kwargs: type(
"Result",
(),
{
"model_dump": lambda self, **_kwargs: {
"status": "ok",
"matches": 1,
"false_positives": 0,
"false_negatives": 0,
"precision": 1.0,
"recall": 1.0,
"f1_score": 1.0,
"mean_iou": 1.0,
"iou_threshold": 0.5,
"warnings": [],
}
},
)(),
)
response = compare_candidate_with_reference(
payload=QaProviderComparisonRequest(
candidate_dataset_id=candidate_dataset_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
),
db=db,
)
persisted_quality_checks = [item for item in db.added if isinstance(item, QualityCheck)]
persisted_metrics = [item for item in db.added if isinstance(item, Metric)]
assert response["data"]["result_json"]["quality_check_id"] == str(persisted_quality_checks[0].id)
assert persisted_quality_checks[0].job_id == job_id
assert persisted_quality_checks[0].candidate_dataset_id == candidate_dataset_id
assert persisted_quality_checks[0].reference_dataset_id == reference_dataset_id
assert [metric.metric_key for metric in persisted_metrics] == [
"precision",
"recall",
"f1",
"mean_iou",
"false_positive_count",
"false_negative_count",
]
@@ -0,0 +1,135 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
from app.providers.registry import (
get_provider_dataset_mapping,
get_provider,
import_provider_dataset,
list_provider_capabilities,
)
def test_provider_registry_lists_sprint7b_providers() -> None:
providers = {provider.provider_name: provider for provider in list_provider_capabilities()}
assert set(providers) == {"grb", "osm", "manual", "fixture"}
assert providers["grb"].authority_level == "authoritative"
assert providers["grb"].configured is False
assert providers["grb"].status == "not_configured"
assert providers["osm"].authority_level == "contextual"
assert providers["osm"].configured is False
assert providers["osm"].status == "not_configured"
assert providers["manual"].authority_level == "manual"
assert providers["manual"].configured is True
assert providers["manual"].status == "configured"
assert providers["fixture"].authority_level == "fixture"
assert providers["fixture"].configured is True
assert providers["fixture"].status == "configured"
def test_provider_capabilities_include_required_metadata() -> None:
grb = get_provider("grb").capability.to_dict()
assert grb["provider_name"] == "grb"
assert grb["display_name"] == "GRB"
assert grb["supported_layers"] == ["buildings", "roads", "parcels"]
assert grb["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert grb["supported_query_modes"] == ["area"]
assert grb["limitation_message"]
assert grb["attribution"]
assert grb["license_note"]
def test_provider_to_dataset_mapping_is_enforced() -> None:
assert get_provider_dataset_mapping("grb").model_dump() == {
"provider_name": "grb",
"dataset_role": "reference",
"source_name": "grb",
"reference_required": True,
"write_path": "DatasetService",
}
assert get_provider_dataset_mapping("manual").dataset_role == "reference"
assert get_provider_dataset_mapping("fixture").source_name == "fixture"
assert get_provider_dataset_mapping("osm").dataset_role == "source"
assert get_provider_dataset_mapping("osm", requested_dataset_role="reference").dataset_role == "reference"
def test_grb_osm_import_contract_returns_not_configured_without_fetching() -> None:
grb = import_provider_dataset("grb", project_id="project", area_id="area", layers=["buildings"])
osm = import_provider_dataset("osm", project_id="project", area_id="area", layers=["buildings"])
assert grb.status == "not_configured"
assert grb.dataset_id is None
assert "No live" in grb.message
assert osm.status == "not_configured"
assert osm.dataset_id is None
def test_manual_fixture_import_contract_points_to_existing_flows() -> None:
manual = import_provider_dataset("manual", project_id="project", area_id=None, layers=["buildings"])
fixture = import_provider_dataset("fixture", project_id="project", area_id=None, layers=["buildings"])
assert manual.status == "upload_flow_required"
assert "upload" in manual.message.lower()
assert fixture.status == "fixture_flow_required"
assert "fixture" in fixture.message.lower()
def test_provider_api_envelopes_and_invalid_provider() -> None:
client = TestClient(app)
list_response = client.get("/api/v1/external/providers")
assert list_response.status_code == 200
assert {provider["provider_name"] for provider in list_response.json()["data"]["providers"]} == {
"grb",
"osm",
"manual",
"fixture",
}
detail_response = client.get("/api/v1/external/providers/grb")
assert detail_response.status_code == 200
assert detail_response.json()["data"]["provider_name"] == "grb"
layers_response = client.get("/api/v1/external/providers/osm/layers")
assert layers_response.status_code == 200
assert layers_response.json()["data"]["layers"] == ["buildings", "roads", "water", "landuse"]
status_response = client.get("/api/v1/external/providers/manual/status")
assert status_response.status_code == 200
assert status_response.json()["data"]["configured"] is True
invalid_response = client.get("/api/v1/external/providers/unknown")
assert invalid_response.status_code == 404
assert invalid_response.json()["error"]["code"] == "PROVIDER_NOT_FOUND"
def test_provider_import_api_returns_clear_not_configured_response() -> None:
client = TestClient(app)
response = client.post(
"/api/v1/external/providers/grb/import",
json={
"project_id": "project",
"area_id": "area",
"layers": ["buildings"],
},
)
assert response.status_code == 200
assert response.json()["data"]["provider_name"] == "grb"
assert response.json()["data"]["status"] == "not_configured"
assert response.json()["data"]["dataset_id"] is None
def test_live_migration_smoke_script_exists() -> None:
from pathlib import Path
script = Path(__file__).parents[2] / "scripts" / "live_migration_smoke.sh"
text = script.read_text(encoding="utf-8")
assert "alembic upgrade head" in text
assert "SELECT PostGIS_Version()" in text
assert "alembic heads" in text
@@ -0,0 +1,198 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models import AnalysisRun, Dataset, Detection, Job, Project
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
class FakeSession:
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
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 _project_and_dataset(dataset_type: str = "raster"):
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Geel")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="source.tif",
dataset_type=dataset_type,
source="user_upload",
storage_path="storage/uploads/source.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def test_model_registry_returns_detection_placeholders() -> None:
models = {model.model_id: model for model in ModelRegistryService.list_model_capabilities()}
assert set(models) == {"yolo-placeholder", "yolo-configured", "manual-fixture-detector"}
assert models["yolo-placeholder"].task_type == "object_detection"
assert models["yolo-placeholder"].configured is False
assert models["yolo-placeholder"].status == "not_configured"
assert models["yolo-configured"].configured is False
assert models["yolo-configured"].status == "not_configured"
assert models["manual-fixture-detector"].configured is True
assert "fixture" in models["manual-fixture-detector"].limitation_message.lower()
def test_unavailable_detector_creates_failed_run_and_job_without_detections() -> None:
db, project_id, dataset_id = _project_and_dataset()
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-placeholder",
confidence_threshold=0.5,
class_filter=["building"],
parameters_json={},
)
runs = [item for item in db.added if isinstance(item, AnalysisRun)]
jobs = [item for item in db.added if isinstance(item, Job)]
detections = [item for item in db.added if isinstance(item, Detection)]
assert result.status == "failed"
assert result.error_code == "DETECTION_MODEL_UNAVAILABLE"
assert result.detection_count == 0
assert runs[0].analysis_type == "detection"
assert runs[0].status == "failed"
assert jobs[0].job_type == "detection.run"
assert jobs[0].status == "failed"
assert detections == []
def test_non_raster_dataset_request_is_rejected() -> None:
db, project_id, dataset_id = _project_and_dataset(dataset_type="vector")
with pytest.raises(Exception) as exc_info:
DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-placeholder",
confidence_threshold=0.5,
)
assert getattr(exc_info.value, "code", None) == "INVALID_DATASET_TYPE"
def test_fixture_detector_persists_detections_only_with_explicit_fixture_mode() -> None:
db, project_id, dataset_id = _project_and_dataset()
with pytest.raises(Exception) as exc_info:
DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="manual-fixture-detector",
confidence_threshold=0.5,
parameters_json={"fixture_detections": []},
)
assert getattr(exc_info.value, "code", None) == "FIXTURE_MODE_REQUIRED"
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="manual-fixture-detector",
confidence_threshold=0.5,
class_filter=["building"],
parameters_json={
"fixture_mode": True,
"fixture_detections": [
{
"class_name": "building",
"confidence": 0.92,
"bbox_json": {"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[4.0, 51.0],
[4.1, 51.0],
[4.1, 51.1],
[4.0, 51.1],
[4.0, 51.0],
]
],
},
"properties_json": {"source": "unit-test-fixture"},
}
],
},
)
detections = [item for item in db.added if isinstance(item, Detection)]
assert result.status == "success"
assert result.detection_count == 1
assert detections[0].project_id == project_id
assert detections[0].dataset_id == dataset_id
assert detections[0].analysis_run_id == result.analysis_run_id
assert detections[0].model_name == "manual-fixture-detector"
assert detections[0].class_name == "building"
assert detections[0].confidence == 0.92
assert detections[0].bbox_json == {"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12}
def test_detection_models_api_uses_envelope() -> None:
response = TestClient(app).get("/api/v1/detection/models")
assert response.status_code == 200
assert "data" in response.json()
assert {model["model_id"] for model in response.json()["data"]["models"]} == {
"yolo-placeholder",
"yolo-configured",
"manual-fixture-detector",
}
def test_sprint8_migration_declares_detection_foundation() -> None:
migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "202606120800_sprint8_detection_foundation.py"
migration_text = migration_path.read_text(encoding="utf-8")
for required_text in (
"detections",
"analysis_runs",
"dataset_id",
"job_id",
"model_name",
"model_version",
"result_json",
"ix_detections_project_id",
"ix_detections_dataset_id",
"ix_detections_analysis_run_id",
"ix_detections_class_name",
"ix_detections_geometry",
'postgresql_using="gist"',
):
assert required_text in migration_text
@@ -0,0 +1,304 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
from app.core.config import Settings
from app.models import AnalysisRun, Dataset, Detection, Job, Project
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
class FakeSession:
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
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)
class AvailableAdapter:
@staticmethod
def dependencies_available() -> bool:
return True
class MissingDependencyAdapter:
@staticmethod
def dependencies_available() -> bool:
return False
class MockYoloAdapter:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.loaded_model_path: Path | None = None
@staticmethod
def dependencies_available() -> bool:
return True
def load_model(self, model_path: Path):
self.loaded_model_path = model_path
return object()
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
assert tile_path.name == "tile_0000.tif"
assert confidence_threshold == 0.5
return [
{
"class_name": "building",
"confidence": 0.91,
"bbox": [10.0, 20.0, 30.0, 40.0],
"properties": {"adapter": "mock"},
}
]
def _project_and_dataset(dataset_type: str = "raster"):
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Geel")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="source.tif",
dataset_type=dataset_type,
source="user_upload",
storage_path="storage/uploads/source.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def _settings(tmp_path: Path, **overrides) -> Settings:
model_path = tmp_path / "model.pt"
values = {
"yolo_enabled": True,
"yolo_model_path": str(model_path),
"yolo_max_tiles": 4,
}
values.update(overrides)
return Settings(**values)
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
tiles = []
for index in range(tile_count):
tile_path = tmp_path / f"tile_{index:04d}.tif"
tile_path.write_bytes(b"fixture")
tiles.append(
{
"path": str(tile_path),
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"index": index,
}
)
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"tile_set_id": "tiles-fixture",
"source_dataset_id": str(uuid4()),
"source_raster_id": str(uuid4()),
"tile_size": 100,
"overlap": 0,
"count": tile_count,
"tiles": tiles,
}
),
encoding="utf-8",
)
return manifest_path
def test_yolo_configured_model_reports_not_configured_when_disabled(tmp_path: Path) -> None:
settings = _settings(tmp_path, yolo_enabled=False)
models = {model.model_id: model for model in ModelRegistryService.list_model_capabilities(settings=settings)}
assert "yolo-configured" in models
assert models["yolo-configured"].configured is False
assert models["yolo-configured"].status == "not_configured"
def test_yolo_configured_model_reports_dependency_unavailable(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path))
model = ModelRegistryService.get_model_capability(
"yolo-configured",
settings=settings,
yolo_adapter_class=MissingDependencyAdapter,
)
assert model is not None
assert model.configured is False
assert model.status == "dependency_unavailable"
def test_yolo_configured_model_reports_configured_with_local_model_and_dependencies(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path))
model = ModelRegistryService.get_model_capability("yolo-configured", settings=settings, yolo_adapter_class=AvailableAdapter)
assert model is not None
assert model.configured is True
assert model.status == "configured"
assert model.version == settings.yolo_model_version
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path)
with pytest.raises(Exception) as exc_info:
DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
settings=settings,
yolo_adapter_class=AvailableAdapter,
)
assert getattr(exc_info.value, "code", None) == "DETECTION_TILE_MANIFEST_REQUIRED"
def test_yolo_run_rejects_manifest_over_tile_limit(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path), yolo_max_tiles=1)
manifest_path = _manifest(tmp_path, tile_count=2)
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_adapter_class=MockYoloAdapter,
)
assert result.status == "failed"
assert result.error_code == "DETECTION_TILE_LIMIT_EXCEEDED"
def test_yolo_run_rejects_missing_tile_manifest_file(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path))
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
tile_manifest_path=str(tmp_path / "missing-manifest.json"),
settings=settings,
yolo_adapter_class=MockYoloAdapter,
)
assert result.status == "failed"
assert result.error_code == "DETECTION_TILE_MANIFEST_NOT_FOUND"
def test_yolo_run_rejects_invalid_tile_manifest_json(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path))
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text("{not-json", encoding="utf-8")
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_adapter_class=MockYoloAdapter,
)
assert result.status == "failed"
assert result.error_code == "DETECTION_TILE_MANIFEST_INVALID"
def test_pixel_bbox_to_epsg4326_polygon_from_gdal_transform() -> None:
polygon = pixel_bbox_to_epsg4326_polygon(
bbox=[10, 20, 30, 40],
tile={
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"bounds": [4.0, 51.0, 5.0, 52.0],
},
crs="EPSG:4326",
)
assert polygon.bounds == pytest.approx((4.1, 51.6, 4.3, 51.8))
def test_yolo_run_persists_mocked_georeferenced_detections(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path), yolo_model_version="local-test")
manifest_path = _manifest(tmp_path, tile_count=1)
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
class_filter=["building"],
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_adapter_class=MockYoloAdapter,
)
detections = [item for item in db.added if isinstance(item, Detection)]
runs = [item for item in db.added if isinstance(item, AnalysisRun)]
jobs = [item for item in db.added if isinstance(item, Job)]
assert result.status == "success"
assert result.detection_count == 1
assert detections[0].model_name == "yolo-configured"
assert detections[0].model_version == "local-test"
assert detections[0].class_name == "building"
assert detections[0].confidence == 0.91
assert detections[0].source_tile_path.endswith("tile_0000.tif")
assert detections[0].bbox_json == {"x_min": 10.0, "y_min": 20.0, "x_max": 30.0, "y_max": 40.0}
assert detections[0].properties_json == {"adapter": "mock", "tile_index": 0}
assert runs[0].status == "success"
assert jobs[0].status == "success"
@@ -0,0 +1,276 @@
from __future__ import annotations
import json
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape
from shapely.geometry import box
from app.main import app
from app.db.session import get_db
from app.models import AnalysisRun, Dataset, Detection, Job, Metric, Project, QualityCheck, VectorFeature
from app.services.detection_service import DetectionService
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:
if operator.__name__ == "eq":
self.rows = [row for row in self.rows if getattr(row, name) == value]
elif operator.__name__ == "ge":
self.rows = [row for row in self.rows if getattr(row, name) >= value]
return self
def order_by(self, *_args):
return self
def all(self):
return list(self.rows)
def first(self):
return self.rows[0] if self.rows else None
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 _detection(project_id, dataset_id, analysis_run_id, class_name="building", confidence=0.91, geom=None):
return Detection(
id=uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run_id,
job_id=uuid4(),
model_name="yolo-configured",
model_version="local-test",
class_name=class_name,
confidence=confidence,
geometry=from_shape(geom or box(4.0, 51.0, 4.1, 51.1), srid=4326),
bbox_json={"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12},
source_tile_path="storage/tiles/tile_0000.tif",
)
def test_detection_geojson_feature_collection_shape() -> None:
project_id = uuid4()
dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id)
db = FakeSession(
objects={(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, analysis_type="detection", status="success", parameters_json={})},
query_rows={Detection: [detection]},
)
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
assert feature_collection["type"] == "FeatureCollection"
assert len(feature_collection["features"]) == 1
feature = feature_collection["features"][0]
assert feature["geometry"]["type"] == "Polygon"
assert feature["properties"]["detection_id"] == str(detection.id)
assert feature["properties"]["class_name"] == "building"
assert feature["properties"]["confidence"] == 0.91
assert feature["properties"]["model_name"] == "yolo-configured"
assert feature["properties"]["analysis_run_id"] == str(analysis_run_id)
assert feature["properties"]["dataset_id"] == str(dataset_id)
assert feature["properties"]["job_id"] == str(detection.job_id)
assert feature["properties"]["source_tile_path"] == "storage/tiles/tile_0000.tif"
assert feature["properties"]["bbox_json"] == {"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12}
def test_detection_list_filters_by_dataset_class_and_confidence() -> None:
project_id = uuid4()
dataset_id = uuid4()
other_dataset_id = uuid4()
analysis_run_id = uuid4()
rows = [
_detection(project_id, dataset_id, analysis_run_id, "building", 0.91),
_detection(project_id, dataset_id, analysis_run_id, "road", 0.95),
_detection(project_id, dataset_id, analysis_run_id, "building", 0.25),
_detection(project_id, other_dataset_id, analysis_run_id, "building", 0.99),
]
db = FakeSession(
objects={(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, analysis_type="detection", status="success", parameters_json={})},
query_rows={Detection: rows},
)
result = DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name="building",
min_confidence=0.5,
)
assert result.total == 1
assert result.items[0].class_name == "building"
assert result.items[0].confidence == 0.91
def test_detection_detail_returns_one_detection() -> None:
project_id = uuid4()
dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id)
db = FakeSession(objects={(Detection, detection.id): detection})
result = DetectionService.get_detection(db, detection.id)
assert result.id == detection.id
assert result.class_name == "building"
def test_detection_models_api_envelope_still_canonical() -> None:
response = TestClient(app).get("/api/v1/detection/models")
assert response.status_code == 200
assert "data" in response.json()
assert "models" in response.json()["data"]
def test_detection_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
analysis_run_id = uuid4()
monkeypatch.setattr(
"app.api.routes.detection.DetectionService.detections_to_geojson",
lambda *_args, **_kwargs: {"type": "FeatureCollection", "features": []},
)
app.dependency_overrides[get_db] = lambda: FakeSession()
try:
response = TestClient(app).get(f"/api/v1/detection/runs/{analysis_run_id}/geojson")
finally:
app.dependency_overrides.pop(get_db, None)
assert response.status_code == 200
assert response.json() == {"data": {"type": "FeatureCollection", "features": []}}
def test_detection_qa_persists_quality_check_and_metrics() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="manual",
dataset_role="reference",
)
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(0, 0, 1, 1), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
quality_checks = [item for item in db.added if isinstance(item, QualityCheck)]
metrics = [item for item in db.added if isinstance(item, Metric)]
assert result["matches"] == 1
assert result["precision"] == 1.0
assert result["recall"] == 1.0
assert result["f1_score"] == 1.0
assert result["quality_check_id"] == str(quality_checks[0].id)
assert quality_checks[0].analysis_run_id == analysis_run_id
assert quality_checks[0].reference_dataset_id == reference_dataset_id
assert [metric.metric_key for metric in metrics] == [
"precision",
"recall",
"f1",
"mean_iou",
"false_positive_count",
"false_negative_count",
]
def test_detection_qa_no_match_case_persists_zero_scores() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="manual",
dataset_role="reference",
)
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(10, 10, 11, 11), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert result["matches"] == 0
assert result["false_positives"] == 1
assert result["false_negatives"] == 1
assert result["precision"] == 0.0
assert result["recall"] == 0.0
assert result["f1_score"] == 0.0
@@ -0,0 +1,418 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape
from shapely.geometry import MultiPolygon, box, mapping
from app.db.session import get_db
from app.main import app
from app.models import AnalysisRun, Dataset, Job, Metric, Project, QualityCheck, Segmentation, VectorFeature
from app.services.model_registry_service import ModelRegistryService
from app.services.segmentation_service import SegmentationService
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:
if operator.__name__ == "eq":
self.rows = [row for row in self.rows if getattr(row, name) == value]
elif operator.__name__ == "ge":
self.rows = [row for row in self.rows if getattr(row, name) >= value]
return self
def order_by(self, *_args):
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 _project_and_dataset(dataset_type: str = "raster"):
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Geel")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="source.tif",
dataset_type=dataset_type,
source="user_upload",
storage_path="storage/uploads/source.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def _segmentation(project_id, dataset_id, analysis_run_id, class_name="vegetation", confidence=0.81, geom=None):
segmentation_id = uuid4()
return Segmentation(
id=segmentation_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run_id,
job_id=uuid4(),
model_name="fixture-segmenter",
model_version="fixture-v1",
class_name=class_name,
confidence=confidence,
geometry=from_shape(geom or MultiPolygon([box(4.0, 51.0, 4.1, 51.1)]), srid=4326),
bbox_json={"min_x": 4.0, "min_y": 51.0, "max_x": 4.1, "max_y": 51.1},
area_m2=123.4,
mask_path=f"storage/masks/{project_id}/{analysis_run_id}/tile_0/mask_{segmentation_id}.png",
source_tile_path="storage/tiles/tile_0000.tif",
tile_index=0,
properties_json={"source": "unit-test-fixture"},
provenance_json={"fixture_mode": True},
)
def test_model_registry_returns_segmentation_states() -> None:
models = {model.model_id: model for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")}
assert set(models) == {"segmentation-placeholder", "fixture-segmenter", "yolo-seg-configured", "sam-configured"}
assert models["segmentation-placeholder"].task_type == "segmentation"
assert models["segmentation-placeholder"].status == "not_configured"
assert models["fixture-segmenter"].configured is True
assert "fixture" in models["fixture-segmenter"].limitation_message.lower()
assert models["yolo-seg-configured"].status == "not_configured"
assert models["sam-configured"].status == "not_configured"
def test_unavailable_segmentation_model_creates_failed_run_without_segmentations() -> None:
db, project_id, dataset_id = _project_and_dataset()
result = SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="segmentation-placeholder",
confidence_threshold=0.5,
)
runs = [item for item in db.added if isinstance(item, AnalysisRun)]
jobs = [item for item in db.added if isinstance(item, Job)]
segmentations = [item for item in db.added if isinstance(item, Segmentation)]
assert result.status == "failed"
assert result.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
assert result.segmentation_count == 0
assert runs[0].analysis_type == "segmentation"
assert runs[0].status == "failed"
assert jobs[0].job_type == "segmentation.run"
assert jobs[0].status == "failed"
assert segmentations == []
def test_fixture_segmenter_requires_explicit_mode_and_persists_segmentations() -> None:
db, project_id, dataset_id = _project_and_dataset()
with pytest.raises(Exception) as exc_info:
SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="fixture-segmenter",
confidence_threshold=0.5,
parameters_json={"fixture_segmentations": []},
)
assert getattr(exc_info.value, "code", None) == "FIXTURE_MODE_REQUIRED"
geometry = mapping(box(4.0, 51.0, 4.1, 51.1))
result = SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="fixture-segmenter",
confidence_threshold=0.5,
class_filter=["vegetation"],
parameters_json={
"fixture_mode": True,
"fixture_segmentations": [
{
"class_name": "vegetation",
"confidence": 0.88,
"geometry": geometry,
"bbox_json": {"min_x": 4.0, "min_y": 51.0, "max_x": 4.1, "max_y": 51.1},
"source_tile_path": "storage/tiles/tile_0000.tif",
"tile_index": 0,
"properties_json": {"source": "unit-test-fixture"},
"provenance_json": {"crs": "EPSG:4326"},
}
],
},
)
segmentations = [item for item in db.added if isinstance(item, Segmentation)]
assert result.status == "success"
assert result.segmentation_count == 1
assert segmentations[0].project_id == project_id
assert segmentations[0].dataset_id == dataset_id
assert segmentations[0].analysis_run_id == result.analysis_run_id
assert segmentations[0].model_name == "fixture-segmenter"
assert segmentations[0].class_name == "vegetation"
assert segmentations[0].confidence == 0.88
assert segmentations[0].mask_path.endswith(f"mask_{segmentations[0].id}.png")
def test_non_raster_dataset_request_is_rejected() -> None:
db, project_id, dataset_id = _project_and_dataset(dataset_type="vector")
with pytest.raises(Exception) as exc_info:
SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="segmentation-placeholder",
confidence_threshold=0.5,
)
assert getattr(exc_info.value, "code", None) == "INVALID_DATASET_TYPE"
def test_invalid_empty_fixture_geometry_is_rejected() -> None:
db, project_id, dataset_id = _project_and_dataset()
with pytest.raises(Exception) as exc_info:
SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="fixture-segmenter",
confidence_threshold=0.5,
parameters_json={
"fixture_mode": True,
"fixture_segmentations": [
{"class_name": "vegetation", "confidence": 0.9, "geometry": {"type": "Polygon", "coordinates": []}},
],
},
)
assert getattr(exc_info.value, "code", None) == "INVALID_FIXTURE_GEOMETRY"
def test_segmentation_geojson_feature_collection_shape_and_provenance() -> None:
project_id = uuid4()
dataset_id = uuid4()
analysis_run_id = uuid4()
segmentation = _segmentation(project_id, dataset_id, analysis_run_id)
db = FakeSession(
objects={(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, analysis_type="segmentation", status="success", parameters_json={})},
query_rows={Segmentation: [segmentation]},
)
feature_collection = SegmentationService.segmentations_to_geojson(db, analysis_run_id=analysis_run_id)
assert feature_collection["type"] == "FeatureCollection"
feature = feature_collection["features"][0]
assert feature["geometry"]["type"] == "MultiPolygon"
assert feature["properties"]["segmentation_id"] == str(segmentation.id)
assert feature["properties"]["class_name"] == "vegetation"
assert feature["properties"]["confidence"] == 0.81
assert feature["properties"]["area_m2"] == 123.4
assert feature["properties"]["model_name"] == "fixture-segmenter"
assert feature["properties"]["analysis_run_id"] == str(analysis_run_id)
assert feature["properties"]["dataset_id"] == str(dataset_id)
assert feature["properties"]["job_id"] == str(segmentation.job_id)
assert feature["properties"]["source_tile_path"] == "storage/tiles/tile_0000.tif"
assert feature["properties"]["tile_index"] == 0
assert feature["properties"]["mask_path"] == segmentation.mask_path
assert feature["properties"]["bbox_json"] == segmentation.bbox_json
assert feature["properties"]["provenance_json"] == {"fixture_mode": True}
def test_segmentation_qa_persists_quality_check_and_metrics() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
segmentation = _segmentation(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="manual",
dataset_role="reference",
)
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="vegetation",
geometry=from_shape(box(0, 0, 1, 1), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="segmentation", status="success", parameters_json={}),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Segmentation: [segmentation], VectorFeature: [reference_feature]},
)
result = SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
quality_checks = [item for item in db.added if isinstance(item, QualityCheck)]
metrics = [item for item in db.added if isinstance(item, Metric)]
assert result["matches"] == 1
assert result["precision"] == 1.0
assert result["recall"] == 1.0
assert result["f1_score"] == 1.0
assert quality_checks[0].check_type == "segmentations_vs_reference"
assert quality_checks[0].analysis_run_id == analysis_run_id
assert [metric.metric_key for metric in metrics] == [
"precision",
"recall",
"f1",
"mean_iou",
"false_positive_count",
"false_negative_count",
]
def test_segmentation_qa_no_match_case_persists_zero_scores() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
segmentation = _segmentation(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="manual",
dataset_role="reference",
)
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="vegetation",
geometry=from_shape(box(10, 10, 11, 11), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="segmentation", status="success", parameters_json={}),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Segmentation: [segmentation], VectorFeature: [reference_feature]},
)
result = SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert result["matches"] == 0
assert result["false_positives"] == 1
assert result["false_negatives"] == 1
assert result["precision"] == 0.0
assert result["recall"] == 0.0
assert result["f1_score"] == 0.0
def test_segmentation_models_api_uses_envelope() -> None:
response = TestClient(app).get("/api/v1/segmentation/models")
assert response.status_code == 200
assert "data" in response.json()
assert {model["model_id"] for model in response.json()["data"]["models"]} == {
"segmentation-placeholder",
"fixture-segmenter",
"yolo-seg-configured",
"sam-configured",
}
def test_segmentation_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
analysis_run_id = uuid4()
monkeypatch.setattr(
"app.api.routes.segmentation.SegmentationService.segmentations_to_geojson",
lambda *_args, **_kwargs: {"type": "FeatureCollection", "features": []},
)
app.dependency_overrides[get_db] = lambda: FakeSession()
try:
response = TestClient(app).get(f"/api/v1/segmentation/runs/{analysis_run_id}/geojson")
finally:
app.dependency_overrides.pop(get_db, None)
assert response.status_code == 200
assert response.json() == {"data": {"type": "FeatureCollection", "features": []}}
def test_sprint9_migration_declares_segmentation_foundation() -> None:
migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "202606120900_sprint9_segmentation_foundation.py"
migration_text = migration_path.read_text(encoding="utf-8")
for required_text in (
"segmentations",
"project_id",
"dataset_id",
"job_id",
"analysis_run_id",
"model_name",
"model_version",
"class_name",
"confidence",
"MultiPolygon",
"bbox_json",
"area_m2",
"mask_path",
"source_tile_path",
"tile_index",
"properties_json",
"provenance_json",
"ix_segmentations_project_id",
"ix_segmentations_dataset_id",
"ix_segmentations_analysis_run_id",
"ix_segmentations_job_id",
"ix_segmentations_class_name",
"ix_segmentations_geometry",
'postgresql_using="gist"',
):
assert required_text in migration_text
+28
View File
@@ -0,0 +1,28 @@
from pathlib import Path
from types import SimpleNamespace
from app.services.storage_service import StorageService
def test_persist_dataset_file_records_metadata(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(
"app.services.storage_service.get_settings",
lambda: SimpleNamespace(storage_root=str(tmp_path)),
)
metadata = StorageService.persist_dataset_file(
project_id="project-123",
dataset_id="dataset-456",
dataset_type="vector",
original_filename="../weird name!@#.geojson",
content=b"example-bytes",
content_type="application/geo+json",
)
assert metadata["original_filename"] == "weird name___.geojson"
assert metadata["stored_filename"] == "dataset-456_weird name___.geojson"
assert metadata["content_type"] == "application/geo+json"
assert metadata["size_bytes"] == 13
assert len(metadata["checksum_sha256"]) == 64
assert Path(metadata["storage_path"]).exists()
assert str(Path(tmp_path, "uploads", "project-123", "vector", "dataset-456")) in metadata["storage_path"]
@@ -0,0 +1,194 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID, uuid4
from app.core.errors import AppError
from app.models import Dataset
from app.services.storage_service import StorageService
from app.services.vector_operations_service import VectorOperationsService
class FakeSession:
def __init__(self, datasets):
self.datasets = {dataset.id: dataset for dataset in datasets}
self.added = []
def get(self, model, item_id):
return self.datasets.get(item_id)
def add(self, value):
self.added.append(value)
def commit(self):
return None
def refresh(self, _value):
return None
def _make_vector_dataset(dataset_id: UUID, project_id: UUID, raw: str) -> Dataset:
path = Path(f"./tests/.tmp_{dataset_id}.geojson")
path.write_text(raw, encoding="utf-8")
return Dataset(
id=dataset_id,
project_id=project_id,
name=f"{dataset_id}.geojson",
dataset_type="vector",
source="test",
storage_path=str(path),
original_filename=f"{dataset_id}.geojson",
stored_filename=f"{dataset_id}.geojson",
content_type="application/geo+json",
)
def test_vector_inspect_extracts_feature_count_and_bbox(tmp_path) -> None:
dataset_id = uuid4()
project_id = uuid4()
source_path = tmp_path / f"{dataset_id}.geojson"
source_path.write_text(
'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[4.1,51.2]}},{"type":"Feature","geometry":{"type":"Point","coordinates":[4.2,51.3]}}]}',
encoding="utf-8",
)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="vector.geojson",
dataset_type="vector",
source="test",
storage_path=str(source_path),
original_filename="vector.geojson",
stored_filename="vector.geojson",
content_type="application/geo+json",
)
db = FakeSession([dataset])
payload = VectorOperationsService.inspect(db, dataset_id)
assert payload.feature_count == 2
assert payload.geometry_type_summary["Point"] == 2
assert payload.bounds_json == {"min_x": 4.1, "min_y": 51.2, "max_x": 4.2, "max_y": 51.3}
def test_vector_bbox_and_stats_share_summary(tmp_path) -> None:
dataset_id = uuid4()
project_id = uuid4()
source_path = tmp_path / f"{dataset_id}.geojson"
source_path.write_text(
'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[4.0,51.0]}}]}',
encoding="utf-8",
)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="vector.geojson",
dataset_type="vector",
source="test",
storage_path=str(source_path),
original_filename="vector.geojson",
stored_filename="vector.geojson",
content_type="application/geo+json",
)
db = FakeSession([dataset])
bbox = VectorOperationsService.bbox(db, dataset_id)
stats = VectorOperationsService.stats(db, dataset_id)
assert bbox["feature_count"] == 1
assert stats["feature_count"] == 1
assert stats["geometry_type_summary"]["Point"] == 1
def test_vector_intersect_creates_derived_dataset(monkeypatch, tmp_path) -> None:
source_id = uuid4()
target_id = uuid4()
project_id = uuid4()
source_path = tmp_path / f"{source_id}.geojson"
target_path = tmp_path / f"{target_id}.geojson"
source_path.write_text(
'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[4.1,51.2]}}]}',
encoding="utf-8",
)
target_path.write_text(
'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.0,51.0],[4.0,52.0],[5.0,52.0],[5.0,51.0],[4.0,51.0]]]}}]}',
encoding="utf-8",
)
source = Dataset(
id=source_id,
project_id=project_id,
name="source.geojson",
dataset_type="vector",
source="test",
storage_path=str(source_path),
original_filename="source.geojson",
stored_filename="source.geojson",
content_type="application/geo+json",
)
target = Dataset(
id=target_id,
project_id=project_id,
name="target.geojson",
dataset_type="vector",
source="test",
storage_path=str(target_path),
original_filename="target.geojson",
stored_filename="target.geojson",
content_type="application/geo+json",
)
db = FakeSession([source, target])
persisted = {}
def _persist_dataset_file(project_id: str, dataset_id: str, dataset_type: str, original_filename: str, content: bytes, content_type: str | None):
persisted["project_id"] = project_id
persisted["dataset_id"] = dataset_id
output = tmp_path / f"{dataset_id}_{dataset_type}.geojson"
output.write_bytes(content)
return {
"original_filename": original_filename,
"stored_filename": output.name,
"content_type": content_type or "application/geo+json",
"size_bytes": len(content),
"checksum_sha256": "test",
"storage_path": str(output),
}
monkeypatch.setattr(StorageService, "persist_dataset_file", _persist_dataset_file)
derived_id = VectorOperationsService.intersect(db, source_id, target_id, "intersect_output")
assert derived_id is not None
assert isinstance(derived_id, UUID)
assert persisted["project_id"] == str(project_id)
def test_vector_operations_reject_invalid_geometry(tmp_path) -> None:
dataset_id = uuid4()
project_id = uuid4()
source_path = tmp_path / f"{dataset_id}.geojson"
source_path.write_text(
'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":"invalid"}}]}',
encoding="utf-8",
)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="invalid.geojson",
dataset_type="vector",
source="test",
storage_path=str(source_path),
original_filename="invalid.geojson",
stored_filename="invalid.geojson",
content_type="application/geo+json",
)
db = FakeSession([dataset])
try:
VectorOperationsService.inspect(db, dataset_id)
except AppError as exc:
assert exc.code == "INVALID_GEOMETRY"
else:
raise AssertionError("Invalid geometry should raise INVALID_GEOMETRY")