Initial GeoIntel V1 foundation
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 <Kempen>" 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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user