Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
||||
),
|
||||
"shell": (
|
||||
"App.tsx",
|
||||
"WorkbenchApp.tsx",
|
||||
"components/shell/WorkbenchNavigation.tsx",
|
||||
"components/shell/SecondaryDisplay.tsx",
|
||||
"components/inspector/WorkbenchInspector.tsx",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.main import create_app
|
||||
from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation
|
||||
from app.schemas import (
|
||||
DetectionRunListResponse,
|
||||
DetectionRunResponse,
|
||||
SegmentationRunListResponse,
|
||||
SegmentationRunResponse,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
|
||||
|
||||
GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123")
|
||||
OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999")
|
||||
DATASET_ID = UUID("00000000-0000-0000-0000-000000000201")
|
||||
DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202")
|
||||
SEGMENTATION_RUN_ID = UUID("00000000-0000-0000-0000-000000000203")
|
||||
DETECTION_ID = UUID("00000000-0000-0000-0000-000000000204")
|
||||
SEGMENTATION_ID = UUID("00000000-0000-0000-0000-000000000205")
|
||||
EXPORT_ID = UUID("00000000-0000-0000-0000-000000000206")
|
||||
JOB_ID = UUID("00000000-0000-0000-0000-000000000207")
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects: dict[tuple[type, UUID], object]) -> None:
|
||||
self.objects = objects
|
||||
|
||||
def get(self, model, row_id):
|
||||
return self.objects.get((model, row_id))
|
||||
|
||||
|
||||
def _guest_client(monkeypatch, db: FakeSession) -> TestClient:
|
||||
password_hash = AuthService.hash_password(
|
||||
"operator-password",
|
||||
salt=b"guest-scope-test-salt",
|
||||
iterations=100_000,
|
||||
)
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
|
||||
monkeypatch.setenv(
|
||||
"GEOINTEL_AUTH_SESSION_SECRET",
|
||||
"guest-scope-test-session-secret-value",
|
||||
)
|
||||
monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true")
|
||||
monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast")
|
||||
|
||||
client = TestClient(create_app())
|
||||
|
||||
def fake_db():
|
||||
yield db
|
||||
|
||||
client.app.dependency_overrides[get_db] = fake_db
|
||||
token = AuthService.create_session_token(
|
||||
"Gast",
|
||||
get_settings(),
|
||||
role="guest",
|
||||
project_id=GUEST_PROJECT_ID,
|
||||
)
|
||||
client.cookies.set("geointel_session", token)
|
||||
return client
|
||||
|
||||
|
||||
def _project_objects(project_id: UUID, export_path: Path) -> dict[tuple[type, UUID], object]:
|
||||
return {
|
||||
(Dataset, DATASET_ID): Dataset(
|
||||
id=DATASET_ID,
|
||||
project_id=project_id,
|
||||
name="scope-test.tif",
|
||||
dataset_type="raster",
|
||||
source="fixture",
|
||||
),
|
||||
(AnalysisRun, DETECTION_RUN_ID): AnalysisRun(
|
||||
id=DETECTION_RUN_ID,
|
||||
project_id=project_id,
|
||||
dataset_id=DATASET_ID,
|
||||
analysis_type="detection",
|
||||
status="success",
|
||||
parameters_json={},
|
||||
),
|
||||
(AnalysisRun, SEGMENTATION_RUN_ID): AnalysisRun(
|
||||
id=SEGMENTATION_RUN_ID,
|
||||
project_id=project_id,
|
||||
dataset_id=DATASET_ID,
|
||||
analysis_type="segmentation",
|
||||
status="success",
|
||||
parameters_json={},
|
||||
),
|
||||
(Detection, DETECTION_ID): Detection(
|
||||
id=DETECTION_ID,
|
||||
project_id=project_id,
|
||||
dataset_id=DATASET_ID,
|
||||
analysis_run_id=DETECTION_RUN_ID,
|
||||
model_name="fixture-detector",
|
||||
class_name="building",
|
||||
confidence=0.9,
|
||||
geometry="SRID=4326;POINT (5 51)",
|
||||
),
|
||||
(Segmentation, SEGMENTATION_ID): Segmentation(
|
||||
id=SEGMENTATION_ID,
|
||||
project_id=project_id,
|
||||
dataset_id=DATASET_ID,
|
||||
analysis_run_id=SEGMENTATION_RUN_ID,
|
||||
model_name="fixture-segmenter",
|
||||
class_name="building",
|
||||
confidence=0.9,
|
||||
geometry="SRID=4326;MULTIPOLYGON (((5 51, 5.1 51, 5.1 51.1, 5 51)))",
|
||||
),
|
||||
(Export, EXPORT_ID): Export(
|
||||
id=EXPORT_ID,
|
||||
project_id=project_id,
|
||||
export_type="dataset_geojson",
|
||||
storage_path=str(export_path),
|
||||
metadata_json={},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
f"/api/v1/detection/runs/{DETECTION_RUN_ID}",
|
||||
f"/api/v1/detection/runs/{DETECTION_RUN_ID}/detections",
|
||||
f"/api/v1/detection/runs/{DETECTION_RUN_ID}/geojson",
|
||||
f"/api/v1/detection/datasets/{DATASET_ID}/detections",
|
||||
f"/api/v1/detection/datasets/{DATASET_ID}/geojson",
|
||||
f"/api/v1/detection/detections/{DETECTION_ID}",
|
||||
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}",
|
||||
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/segmentations",
|
||||
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/geojson",
|
||||
f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations",
|
||||
f"/api/v1/segmentation/datasets/{DATASET_ID}/geojson",
|
||||
f"/api/v1/segmentation/segmentations/{SEGMENTATION_ID}",
|
||||
f"/api/v1/exports/{EXPORT_ID}",
|
||||
f"/api/v1/exports/{EXPORT_ID}/content",
|
||||
f"/api/v1/exports/{EXPORT_ID}/download",
|
||||
f"/api/v1/exports/projects/{OTHER_PROJECT_ID}/exports",
|
||||
],
|
||||
)
|
||||
def test_matching_guest_query_cannot_authorize_another_projects_resource(
|
||||
path: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
artifact = tmp_path / "other-project.geojson"
|
||||
artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
|
||||
client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
|
||||
|
||||
response = client.get(f"{path}?project_id={GUEST_PROJECT_ID}")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "payload"),
|
||||
[
|
||||
(
|
||||
"/api/v1/detection/run",
|
||||
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
|
||||
),
|
||||
(
|
||||
"/api/v1/detection/run-async",
|
||||
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
|
||||
),
|
||||
(
|
||||
"/api/v1/segmentation/run",
|
||||
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
|
||||
),
|
||||
(
|
||||
"/api/v1/segmentation/run-async",
|
||||
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
|
||||
),
|
||||
(
|
||||
"/api/v1/detection/runs/{run_id}/qa/reference".format(run_id=DETECTION_RUN_ID),
|
||||
{"reference_dataset_id": str(DATASET_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/segmentation/runs/{run_id}/qa/reference".format(run_id=SEGMENTATION_RUN_ID),
|
||||
{"reference_dataset_id": str(DATASET_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/geojson",
|
||||
{"export_kind": "dataset", "dataset_id": str(DATASET_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/geojson",
|
||||
{"export_kind": "detection_run", "analysis_run_id": str(DETECTION_RUN_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/geojson",
|
||||
{"export_kind": "segmentation_run", "analysis_run_id": str(SEGMENTATION_RUN_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/metadata",
|
||||
{"project_id": str(OTHER_PROJECT_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/report",
|
||||
{"project_id": str(OTHER_PROJECT_ID)},
|
||||
),
|
||||
(
|
||||
"/api/v1/exports/map-result",
|
||||
{
|
||||
"project_id": str(OTHER_PROJECT_ID),
|
||||
"mode": "current",
|
||||
"dataset_id": str(DATASET_ID),
|
||||
"bbox": {"min_x": 5.0, "min_y": 51.0, "max_x": 5.1, "max_y": 51.1, "crs": "EPSG:4326"},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_matching_guest_query_cannot_override_post_body_or_target_scope(
|
||||
path: str,
|
||||
payload: dict,
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
artifact = tmp_path / "other-project.geojson"
|
||||
artifact.write_text("{}", encoding="utf-8")
|
||||
client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
|
||||
|
||||
response = client.post(f"{path}?project_id={GUEST_PROJECT_ID}", json=payload)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
|
||||
|
||||
def test_guest_can_still_read_and_download_its_own_resources(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
artifact = tmp_path / "demo.geojson"
|
||||
artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
|
||||
client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
|
||||
suffix = f"?project_id={GUEST_PROJECT_ID}"
|
||||
|
||||
detection = client.get(f"/api/v1/detection/runs/{DETECTION_RUN_ID}{suffix}")
|
||||
segmentation = client.get(f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}{suffix}")
|
||||
export = client.get(f"/api/v1/exports/{EXPORT_ID}{suffix}")
|
||||
download = client.get(f"/api/v1/exports/{EXPORT_ID}/download{suffix}")
|
||||
|
||||
assert detection.status_code == 200
|
||||
assert segmentation.status_code == 200
|
||||
assert export.status_code == 200
|
||||
assert download.status_code == 200
|
||||
assert download.json()["type"] == "FeatureCollection"
|
||||
|
||||
|
||||
def test_guest_run_lists_and_new_runs_remain_bound_to_the_session_project(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
artifact = tmp_path / "demo.geojson"
|
||||
artifact.write_text("{}", encoding="utf-8")
|
||||
client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
|
||||
observed: list[UUID] = []
|
||||
|
||||
def detection_list(_db, *, project_id, **_kwargs):
|
||||
observed.append(project_id)
|
||||
return DetectionRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
|
||||
|
||||
def segmentation_list(_db, *, project_id, **_kwargs):
|
||||
observed.append(project_id)
|
||||
return SegmentationRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
|
||||
|
||||
def detection_run(**kwargs):
|
||||
observed.append(kwargs["project_id"])
|
||||
return DetectionRunResponse(
|
||||
analysis_run_id=DETECTION_RUN_ID,
|
||||
job_id=JOB_ID,
|
||||
project_id=kwargs["project_id"],
|
||||
dataset_id=kwargs["dataset_id"],
|
||||
model_id=kwargs["model_id"],
|
||||
status="success",
|
||||
detection_count=0,
|
||||
message="Demo run completed",
|
||||
)
|
||||
|
||||
def segmentation_run(**kwargs):
|
||||
observed.append(kwargs["project_id"])
|
||||
return SegmentationRunResponse(
|
||||
analysis_run_id=SEGMENTATION_RUN_ID,
|
||||
job_id=JOB_ID,
|
||||
project_id=kwargs["project_id"],
|
||||
dataset_id=kwargs["dataset_id"],
|
||||
model_id=kwargs["model_id"],
|
||||
status="success",
|
||||
segmentation_count=0,
|
||||
message="Demo run completed",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(DetectionService, "list_runs", detection_list)
|
||||
monkeypatch.setattr(SegmentationService, "list_runs", segmentation_list)
|
||||
monkeypatch.setattr(DetectionService, "run_detection", detection_run)
|
||||
monkeypatch.setattr(SegmentationService, "run_segmentation", segmentation_run)
|
||||
|
||||
def enqueue_detection(**kwargs):
|
||||
observed.append(kwargs["project_id"])
|
||||
return Job(
|
||||
id=JOB_ID,
|
||||
job_type="detection.run",
|
||||
status="queued",
|
||||
project_id=kwargs["project_id"],
|
||||
dataset_id=kwargs["dataset_id"],
|
||||
parameters_json={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(DetectionService, "enqueue_detection", enqueue_detection)
|
||||
|
||||
def enqueue_segmentation(**kwargs):
|
||||
observed.append(kwargs["project_id"])
|
||||
return Job(
|
||||
id=JOB_ID,
|
||||
job_type="segmentation.run",
|
||||
status="queued",
|
||||
project_id=kwargs["project_id"],
|
||||
dataset_id=kwargs["dataset_id"],
|
||||
parameters_json={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(SegmentationService, "enqueue_segmentation", enqueue_segmentation)
|
||||
query = f"?project_id={GUEST_PROJECT_ID}"
|
||||
payload = {"project_id": str(GUEST_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}
|
||||
|
||||
responses = [
|
||||
client.get(f"/api/v1/detection/runs{query}"),
|
||||
client.get(f"/api/v1/segmentation/runs{query}"),
|
||||
client.post(f"/api/v1/detection/run{query}", json=payload),
|
||||
client.post(f"/api/v1/detection/run-async{query}", json=payload),
|
||||
client.post(f"/api/v1/segmentation/run{query}", json=payload),
|
||||
client.post(f"/api/v1/segmentation/run-async{query}", json=payload),
|
||||
]
|
||||
|
||||
assert all(response.status_code == 200 for response in responses)
|
||||
assert observed == [GUEST_PROJECT_ID] * 6
|
||||
@@ -18,8 +18,10 @@ import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.services.outbound_request_guard import (
|
||||
_ValidatedRedirects,
|
||||
assert_public_http_url,
|
||||
assert_same_origin_redirect,
|
||||
validated_redirect_opener,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,6 +91,30 @@ class TestRedirects:
|
||||
def test_an_upgrade_to_https_stays_allowed(self) -> None:
|
||||
assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs")
|
||||
|
||||
def test_a_redirect_to_another_port_is_refused(self) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
assert_same_origin_redirect(
|
||||
"https://geo.api.vlaanderen.be/wcs",
|
||||
"https://geo.api.vlaanderen.be:8443/wcs",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
|
||||
|
||||
def test_embedded_credentials_are_refused(self) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
assert_public_http_url("https://operator:secret@geo.example.be/wcs")
|
||||
|
||||
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
|
||||
|
||||
def test_a_redirect_with_an_invalid_port_fails_closed(self) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
assert_same_origin_redirect(
|
||||
"https://geo.api.vlaanderen.be/wcs",
|
||||
"https://geo.api.vlaanderen.be:not-a-port/wcs",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
|
||||
|
||||
|
||||
def test_the_guard_opener_refuses_a_cross_host_redirect() -> None:
|
||||
"""The opener is what the acquisition services actually call."""
|
||||
@@ -249,6 +275,26 @@ def test_a_refused_redirect_is_never_requested() -> None:
|
||||
assert "_RejectRedirects" in handlers
|
||||
|
||||
|
||||
def test_the_default_guard_validates_before_following_a_redirect() -> None:
|
||||
opener = validated_redirect_opener("https://geo.api.vlaanderen.be/wcs")
|
||||
handlers = [type(handler).__name__ for handler in opener.handlers]
|
||||
|
||||
assert "_ValidatedRedirects" in handlers
|
||||
|
||||
handler = _ValidatedRedirects("https://geo.api.vlaanderen.be/wcs")
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
handler.redirect_request(
|
||||
None,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
|
||||
|
||||
|
||||
def test_the_rejecting_handler_returns_no_new_request() -> None:
|
||||
from app.services.outbound_request_guard import _RejectRedirects
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AppError
|
||||
from app.services.segmentation_adapter import YoloSegmentationAdapter
|
||||
|
||||
|
||||
def _settings(*, require_cuda: bool, device: str) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
YOLO_REQUIRE_CUDA=require_cuda,
|
||||
YOLO_DEVICE=device,
|
||||
)
|
||||
|
||||
|
||||
def test_segmentation_runtime_allows_cpu_only_when_cuda_is_not_required() -> None:
|
||||
adapter = YoloSegmentationAdapter(_settings(require_cuda=False, device="cpu"))
|
||||
|
||||
adapter.validate_runtime()
|
||||
|
||||
|
||||
def test_segmentation_runtime_rejects_missing_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
|
||||
)
|
||||
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
adapter.validate_runtime()
|
||||
|
||||
assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_segmentation_runtime_rejects_cpu_device_when_cuda_is_required(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
|
||||
)
|
||||
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cpu"))
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
adapter.validate_runtime()
|
||||
|
||||
assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_MISCONFIGURED"
|
||||
|
||||
|
||||
def test_segmentation_runtime_accepts_configured_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
|
||||
)
|
||||
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
|
||||
|
||||
adapter.validate_runtime()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Regression coverage for bounded, stable segmentation result listings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.routes import segmentation as segmentation_routes
|
||||
from app.db.session import get_db
|
||||
from app.schemas.segmentation import SegmentationListResponse
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
|
||||
|
||||
RUN_ID = UUID("00000000-0000-0000-0000-000000000101")
|
||||
DATASET_ID = UUID("00000000-0000-0000-0000-000000000102")
|
||||
PROJECT_ID = UUID("00000000-0000-0000-0000-000000000103")
|
||||
|
||||
|
||||
def _segmentation(index: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=UUID(int=index + 1),
|
||||
project_id=PROJECT_ID,
|
||||
dataset_id=DATASET_ID,
|
||||
analysis_run_id=RUN_ID,
|
||||
job_id=None,
|
||||
model_name="segmentation-test-model",
|
||||
model_version="1",
|
||||
class_name="building",
|
||||
confidence=0.99 - index / 100,
|
||||
bbox_json=None,
|
||||
area_m2=float(index + 1),
|
||||
mask_path=None,
|
||||
source_tile_path=None,
|
||||
tile_index=index,
|
||||
properties_json={},
|
||||
provenance_json={},
|
||||
created_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
class _Session:
|
||||
def get(self, _model, identifier):
|
||||
if identifier == RUN_ID:
|
||||
return SimpleNamespace(analysis_type="segmentation")
|
||||
return None
|
||||
|
||||
|
||||
def test_service_returns_one_stable_page_with_complete_metadata(monkeypatch) -> None:
|
||||
rows = [_segmentation(index) for index in range(5)]
|
||||
monkeypatch.setattr(
|
||||
SegmentationService,
|
||||
"_query_segmentation_rows",
|
||||
staticmethod(lambda _db, **_filters: rows),
|
||||
)
|
||||
|
||||
result = SegmentationService.list_segmentations(
|
||||
_Session(),
|
||||
analysis_run_id=RUN_ID,
|
||||
dataset_id=DATASET_ID,
|
||||
limit=2,
|
||||
offset=1,
|
||||
)
|
||||
|
||||
assert [item.id for item in result.items] == [rows[1].id, rows[2].id]
|
||||
assert result.total == 5
|
||||
assert result.limit == 2
|
||||
assert result.offset == 1
|
||||
assert result.truncated is True
|
||||
|
||||
|
||||
def test_service_pages_cover_the_stably_ordered_population_once(monkeypatch) -> None:
|
||||
rows = [_segmentation(index) for index in range(5)]
|
||||
monkeypatch.setattr(
|
||||
SegmentationService,
|
||||
"_query_segmentation_rows",
|
||||
staticmethod(lambda _db, **_filters: rows),
|
||||
)
|
||||
|
||||
seen = []
|
||||
for offset in (0, 2, 4):
|
||||
result = SegmentationService.list_segmentations(
|
||||
_Session(),
|
||||
dataset_id=DATASET_ID,
|
||||
limit=2,
|
||||
offset=offset,
|
||||
)
|
||||
seen.extend(item.id for item in result.items)
|
||||
assert result.total == len(rows)
|
||||
assert result.offset == offset
|
||||
|
||||
assert seen == [row.id for row in rows]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected_run_id", "expected_dataset_id"),
|
||||
[
|
||||
(f"/api/v1/segmentation/runs/{RUN_ID}/segmentations", RUN_ID, None),
|
||||
(f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", None, DATASET_ID),
|
||||
],
|
||||
)
|
||||
def test_both_listing_routes_forward_the_page_window_and_return_it(
|
||||
monkeypatch,
|
||||
path: str,
|
||||
expected_run_id: UUID | None,
|
||||
expected_dataset_id: UUID | None,
|
||||
) -> None:
|
||||
calls: list[dict] = []
|
||||
|
||||
def _list(_db, analysis_run_id=None, **parameters):
|
||||
calls.append({"analysis_run_id": analysis_run_id, **parameters})
|
||||
return SegmentationListResponse(
|
||||
items=[],
|
||||
total=9,
|
||||
limit=2,
|
||||
offset=4,
|
||||
truncated=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(SegmentationService, "list_segmentations", staticmethod(_list))
|
||||
app = FastAPI()
|
||||
app.include_router(segmentation_routes.router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_db] = lambda: object()
|
||||
|
||||
response = TestClient(app).get(
|
||||
path,
|
||||
params={"limit": 2, "offset": 4, "class_name": "building", "min_confidence": 0.5},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] == {
|
||||
"items": [],
|
||||
"total": 9,
|
||||
"limit": 2,
|
||||
"offset": 4,
|
||||
"truncated": True,
|
||||
}
|
||||
assert calls == [
|
||||
{
|
||||
"analysis_run_id": expected_run_id,
|
||||
"limit": 2,
|
||||
"offset": 4,
|
||||
"dataset_id": expected_dataset_id,
|
||||
"class_name": "building",
|
||||
"min_confidence": 0.5,
|
||||
}
|
||||
]
|
||||
@@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action()
|
||||
assert "detectionRunBlockedReason" in lab
|
||||
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
|
||||
assert "Klaar om gebouwen te zoeken" in lab
|
||||
assert "disabled={runningDetection || !detectionRunReady}" in lab
|
||||
assert "disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}" in lab
|
||||
assert "detectionJob?.status === 'queued' || detectionJob?.status === 'running'" in lab
|
||||
|
||||
|
||||
def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action() -> None:
|
||||
@@ -26,7 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action
|
||||
assert "segmentationRunBlockedReason" in lab
|
||||
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
|
||||
assert "Analyse" in lab
|
||||
assert "disabled={runningSegmentation || !segmentationRunReady}" in lab
|
||||
assert "disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}" in lab
|
||||
assert "segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'" in lab
|
||||
|
||||
|
||||
def test_ai_lab_guardrail_styles_remain_compact() -> None:
|
||||
|
||||
@@ -28,7 +28,12 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None:
|
||||
|
||||
|
||||
def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None:
|
||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
app = "\n".join(
|
||||
(
|
||||
(ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8"),
|
||||
(ROOT / "frontend" / "src" / "WorkbenchApp.tsx").read_text(encoding="utf-8"),
|
||||
)
|
||||
)
|
||||
lab = "\n".join(
|
||||
(
|
||||
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
|
||||
@@ -40,7 +45,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl
|
||||
assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app
|
||||
assert "setSelectedDetectionModelId('yolo-configured')" in app
|
||||
assert "setDetectionConfidenceThreshold(0.25)" in app
|
||||
assert "loadYoloPreflight(manifestPath).catch(() => null)" in app
|
||||
assert "loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole'))" in app
|
||||
assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))]
|
||||
assert "Gekoppelde beeldtegels" in lab
|
||||
assert "Gekoppelde beeldtegels" in lab
|
||||
|
||||
@@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
|
||||
assert "effectiveModelId" in hook
|
||||
assert "effectiveModelAssetId" in hook
|
||||
assert "await loadDetectionResults(result.analysis_run_id)" in hook
|
||||
assert "model_id: selectedDetectionModelId" in hook
|
||||
assert "model_asset_id: selectedModelAssetId || null" in hook
|
||||
assert "model_id: modelId" in hook
|
||||
assert "model_asset_id: modelAssetId || null" in hook
|
||||
assert "effectiveModelId" in hook
|
||||
assert "effectiveModelAssetId" in hook
|
||||
|
||||
|
||||
def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None:
|
||||
@@ -63,7 +65,8 @@ def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None:
|
||||
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
|
||||
assert "als kwaliteitscontrole in de database bewaard" in lab
|
||||
assert "detectionApi.compareWithReference" in hook
|
||||
assert "await loadQualityChecks(selectedProjectId)" in hook
|
||||
assert "await loadQualityChecks(projectId)" in hook
|
||||
assert "detectionQaRequestSequence.current" in hook
|
||||
assert "Minimale IoU voor een match" in lab
|
||||
assert "detectionQaResult.iou_threshold.toFixed(2)" in lab
|
||||
|
||||
|
||||
Reference in New Issue
Block a user