diff --git a/CHANGELOG.md b/CHANGELOG.md index e806f57c..5c65ea4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # Changelog +## Sprint 21 V1 demo workflow smoke hardening (2026-06-17) + +- Hardened the browser-facing demo/export smoke to verify connected V1 state: project area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summary, persisted QA/QC metrics and export downloads. +- Loading the offline demo workflow in the frontend now opens the candidate vector fixture dataset directly, so the map workbench is populated after the demo action. +- Added regression tests for the strengthened demo smoke and frontend demo dataset loading contract. +- No migrations, provider downloads, AI inference, new dependencies or API route renames were introduced. + ## Sprint 20 selected area map overlay (2026-06-17) - Added persisted AOI GeoJSON to area API responses without changing the database schema. diff --git a/backend/app/services/demo_workflow_service.py b/backend/app/services/demo_workflow_service.py index bf4ec923..8242aa62 100644 --- a/backend/app/services/demo_workflow_service.py +++ b/backend/app/services/demo_workflow_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from datetime import datetime, timezone from pathlib import Path from uuid import UUID, uuid4 @@ -30,6 +31,16 @@ class DemoWorkflowService: @staticmethod def _fixture_path(filename: str) -> Path: + roots: list[Path] = [] + if os.getenv("GEOINTEL_FIXTURES_ROOT"): + roots.append(Path(os.environ["GEOINTEL_FIXTURES_ROOT"])) + roots.extend(parent / "fixtures" / "golden" for parent in Path(__file__).resolve().parents) + roots.append(Path("/app/fixtures/golden")) + + for root in roots: + path = root / filename + if path.exists(): + return path return DemoWorkflowService._repo_root() / "fixtures" / "golden" / filename @staticmethod @@ -194,6 +205,8 @@ class DemoWorkflowService: @staticmethod def seed(db: Session) -> DemoWorkflowResponse: existing = DemoWorkflowService._find_existing_project(db) + reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson") + candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson") if existing: area = db.query(Area).filter(Area.project_id == existing.id).order_by(Area.created_at.asc()).first() reference = ( @@ -229,51 +242,59 @@ class DemoWorkflowService: message="Demo workflow already exists.", created=False, ) + project = existing + created = True + else: + project = Project( + id=uuid4(), + name=DemoWorkflowService.PROJECT_NAME, + description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.", + region="Kempen", + status="active", + ) + db.add(project) + db.commit() + db.refresh(project) + area = None + reference = None + candidate = None + quality_check = None + created = True - project = Project( - id=uuid4(), - name=DemoWorkflowService.PROJECT_NAME, - description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.", - region="Kempen", - status="active", - ) - db.add(project) - db.commit() - db.refresh(project) - - area = DemoWorkflowService._create_area(db, project.id) - reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson") - candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson") - - reference = DemoWorkflowService._create_dataset( - db=db, - project_id=project.id, - area_id=area.id, - filename=DemoWorkflowService.REFERENCE_FILENAME, - payload=reference_payload, - raw=reference_raw, - role="reference", - source_name="fixture", - reference_layer_name="buildings", - ) - candidate = DemoWorkflowService._create_dataset( - db=db, - project_id=project.id, - area_id=area.id, - filename=DemoWorkflowService.CANDIDATE_FILENAME, - payload=candidate_payload, - raw=candidate_raw, - role="source", - source_name="fixture", - reference_layer_name=None, - ) - quality_check = DemoWorkflowService._persist_qa( - db=db, - project_id=project.id, - candidate_dataset_id=candidate.id, - reference_dataset_id=reference.id, - area_id=area.id, - ) + if not area: + area = DemoWorkflowService._create_area(db, project.id) + if not reference: + reference = DemoWorkflowService._create_dataset( + db=db, + project_id=project.id, + area_id=area.id, + filename=DemoWorkflowService.REFERENCE_FILENAME, + payload=reference_payload, + raw=reference_raw, + role="reference", + source_name="fixture", + reference_layer_name="buildings", + ) + if not candidate: + candidate = DemoWorkflowService._create_dataset( + db=db, + project_id=project.id, + area_id=area.id, + filename=DemoWorkflowService.CANDIDATE_FILENAME, + payload=candidate_payload, + raw=candidate_raw, + role="source", + source_name="fixture", + reference_layer_name=None, + ) + if not quality_check: + quality_check = DemoWorkflowService._persist_qa( + db=db, + project_id=project.id, + candidate_dataset_id=candidate.id, + reference_dataset_id=reference.id, + area_id=area.id, + ) return DemoWorkflowResponse( project_id=project.id, @@ -284,5 +305,5 @@ class DemoWorkflowService: metric_count=6, status="ready", message="Demo workflow seeded from explicit local fixtures.", - created=True, + created=created, ) diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index 1716c007..8b91db91 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -97,6 +97,12 @@ def test_compose_waits_for_healthy_database_and_applies_migrations() -> None: assert "sh /app/docker_start.sh" in compose +def test_compose_mounts_demo_fixtures_for_backend_runtime() -> None: + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "./fixtures:/app/fixtures:ro" in compose + + def test_compose_has_backend_and_frontend_healthchecks() -> None: compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") diff --git a/backend/tests/test_readiness_gate.py b/backend/tests/test_readiness_gate.py index 508ee2d9..43cafb7f 100644 --- a/backend/tests/test_readiness_gate.py +++ b/backend/tests/test_readiness_gate.py @@ -27,6 +27,13 @@ def test_demo_export_workflow_script_verifies_export_endpoints() -> None: content = script.read_text(encoding="utf-8") assert "/api/v1/demo/workflow" in content + assert "/areas" in content + assert "/datasets" in content + assert "/content" in content + assert "/vector/summary" in content + assert "GeoJSON Polygon/MultiPolygon geometry" in content + assert "precision" in content + assert "false_negative_count" in content assert "/api/v1/exports/metadata" in content assert "/api/v1/exports/report" in content assert "/api/v1/exports/geojson" in content diff --git a/backend/tests/test_sprint15_demo_workflow.py b/backend/tests/test_sprint15_demo_workflow.py index 1e0dc06c..d0ef7dc2 100644 --- a/backend/tests/test_sprint15_demo_workflow.py +++ b/backend/tests/test_sprint15_demo_workflow.py @@ -55,3 +55,14 @@ def test_demo_workflow_service_uses_explicit_golden_fixtures() -> None: assert DemoWorkflowService.PROJECT_NAME == "GeoIntel Demo - Building QA" assert DemoWorkflowService.REFERENCE_FILENAME == "demo_reference_buildings.geojson" assert DemoWorkflowService.CANDIDATE_FILENAME == "demo_predicted_buildings.geojson" + + +def test_demo_workflow_service_supports_container_fixture_mount() -> None: + service = (DemoWorkflowService._repo_root() / "backend" / "app" / "services" / "demo_workflow_service.py").read_text(encoding="utf-8") + + assert "GEOINTEL_FIXTURES_ROOT" in service + assert 'Path("/app/fixtures/golden")' in service + assert "reference_payload, reference_raw = DemoWorkflowService._load_fixture" in service + assert "project = existing" in service + assert "if not reference:" in service + assert "if not candidate:" in service diff --git a/backend/tests/test_sprint21_demo_workflow_smoke.py b/backend/tests/test_sprint21_demo_workflow_smoke.py new file mode 100644 index 00000000..0bba6984 --- /dev/null +++ b/backend/tests/test_sprint21_demo_workflow_smoke.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_demo_workflow_browser_smoke_script_checks_connected_v1_state() -> None: + script = (ROOT / "scripts" / "verify_demo_export_workflow.sh").read_text(encoding="utf-8") + + assert "/api/v1/demo/workflow" in script + assert "/api/v1/projects/${project_id}/areas" in script + assert "/api/v1/projects/${project_id}/datasets" in script + assert "/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/content" in script + assert "/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/vector/summary" in script + assert "GeoJSON Polygon/MultiPolygon geometry" in script + assert "Candidate vector summary does not report persisted features" in script + + +def test_frontend_demo_action_loads_candidate_dataset_details_for_map_layer() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "const candidateDataset = projectData?.datasets.find" in app + assert "dataset.id === result.candidate_dataset_id" in app + assert "await loadDatasetDetails(result.project_id, candidateDataset)" in app diff --git a/docker-compose.yml b/docker-compose.yml index 3c2b05dc..91a75ab6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - "8000:8000" volumes: - ./storage:/app/storage + - ./fixtures:/app/fixtures:ro command: sh /app/docker_start.sh depends_on: db: diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 28353f00..38ca2c65 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,19 @@ +## Sprint 21 V1 demo workflow smoke hardening (2026-06-17) + +Changed: +- Hardened `scripts/verify_demo_export_workflow.sh` so the explicit offline demo smoke validates area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summaries, persisted QA/QC metrics and export downloads through the frontend proxy. +- Updated the frontend demo workflow action to open the candidate vector fixture dataset after seeding/loading the demo, so the Map Workbench is populated without a manual dataset click. +- Added regression tests for the strengthened smoke script and frontend demo loading contract. +- Updated scripts/frontend documentation, changelog and TODO status. + +Tested: +- Pending validation in this pass: backend compile, backend pytest, readiness, frontend typecheck/build, Alembic checks, demo/export smoke, Tower deploy and browser audit. + +Known limitations: +- The demo smoke intentionally seeds fixture demo data when run; it should be used as an explicit verification command, not as an implicit healthcheck. + +Next recommended pass: +- Add a compact V1 dashboard/status strip for project, AOI, datasets, QA and exports so operators can see readiness at a glance after opening a project. ## Sprint 20 V1 selected area map overlay (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 9fa669b3..974514e5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -9,7 +9,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Enforce Python deprecation warnings as release-readiness failures. - [x] Fix Docker backend package install order and remove mandatory root `.env` dependency. - [x] Add Docker build context ignores for backend and frontend. -- [ ] Run Docker/PostGIS live validation on a machine where Docker is available. +- [x] Run Docker/PostGIS live validation on Tower/Unraid. ## Current implementation status @@ -30,8 +30,8 @@ This file now starts with the current implementation status. Older preparation/b - [x] Project-scoped QA/QC result listing and frontend QA/QC Results panel. - [x] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON. - [x] Lightweight HTML project report artifact export. -- [x] Browser-facing demo/export workflow smoke script. -- [ ] Live Docker/PostGIS validation in this execution environment. +- [x] Browser-facing demo/export workflow smoke script with connected V1 state checks. +- [x] Live Docker/PostGIS validation on Tower/Unraid. - [ ] Real YOLO compatibility smoke with optional AI extras and local model file. - [ ] Further frontend state/module decomposition beyond Sprint 10 extraction. diff --git a/frontend/README.md b/frontend/README.md index 574d562e..f544d6ca 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -151,6 +151,11 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - Added area visibility and opacity controls alongside the existing active vector/result layer controls. - Area list items can select which AOI is shown on the map. +## Sprint 21 additions + +- Loading the explicit demo workflow now opens the candidate vector fixture dataset directly, so the Map Workbench shows the demo vector layer without an extra manual dataset click. +- The demo/export verification script now checks connected V1 state: area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summary, persisted QA/QC metrics and export downloads through the frontend proxy. + ## Release hardening updates - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cebeb0d1..57b61618 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -339,7 +339,9 @@ function App(): JSX.Element { ]) setAreas(areaResponse.items) setDatasets(datasetResponse.items) - if (!selectedClipAreaId && areaResponse.items.length > 0) { + if (areaResponse.items.length === 0) { + setSelectedClipAreaId('') + } else if (!selectedClipAreaId || !areaResponse.items.some((area) => area.id === selectedClipAreaId)) { setSelectedClipAreaId(areaResponse.items[0].id) } if (areaResponse.items.length === 0) { @@ -347,8 +349,10 @@ function App(): JSX.Element { } else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) { setSelectedMapAreaId(areaResponse.items[0].id) } + return { areas: areaResponse.items, datasets: datasetResponse.items } } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data') + return null } finally { setLoadingAreas(false) setLoadingDatasets(false) @@ -759,13 +763,17 @@ function App(): JSX.Element { setSegmentationReferenceDatasetId(result.reference_dataset_id) setDemoWorkflowMessage(result.message) await loadProjects() - await Promise.all([ + const [projectData] = await Promise.all([ loadProjectData(result.project_id), loadDetectionRuns(result.project_id), loadSegmentationRuns(result.project_id), loadQualityChecks(result.project_id), loadExports(result.project_id), ]) + const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id) + if (candidateDataset) { + await loadDatasetDetails(result.project_id, candidateDataset) + } } catch (error) { setErrorMessage(formatError(error, 'Failed to load demo workflow')) } finally { diff --git a/scripts/README.md b/scripts/README.md index d0e83717..a01cccaa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,9 +17,11 @@ Verify the explicit demo workflow plus export artifact path: bash scripts/verify_demo_export_workflow.sh http://192.168.10.150:1202 ``` -The demo/export smoke seeds the offline fixture demo, verifies persisted QA/QC -results, creates metadata/report/vector GeoJSON exports, lists exports and -downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy. +The demo/export smoke is intentionally mutating and idempotent: it seeds the +offline fixture demo if needed, verifies the project area GeoJSON, fixture +datasets, vector FeatureCollection content, vector feature summary, persisted +QA/QC metrics, creates metadata/report/vector GeoJSON exports, lists exports +and downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy. ## Tower deployment diff --git a/scripts/verify_demo_export_workflow.sh b/scripts/verify_demo_export_workflow.sh index 4c557802..6ddd7261 100755 --- a/scripts/verify_demo_export_workflow.sh +++ b/scripts/verify_demo_export_workflow.sh @@ -72,6 +72,72 @@ curl -fsS -X POST "${BASE_URL%/}/api/v1/demo/workflow" > "${TMP_DIR}/demo.json" require_json_data "${TMP_DIR}/demo.json" project_id="$(json_field "${TMP_DIR}/demo.json" "data.project_id")" candidate_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.candidate_dataset_id")" +reference_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.reference_dataset_id")" +area_id="$(json_field "${TMP_DIR}/demo.json" "data.area_id")" +quality_check_id="$(json_field "${TMP_DIR}/demo.json" "data.quality_check_id")" + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}" > "${TMP_DIR}/project.json" +require_json_data "${TMP_DIR}/project.json" + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/areas" > "${TMP_DIR}/areas.json" +require_json_data "${TMP_DIR}/areas.json" +"${PYTHON_BIN}" - "${TMP_DIR}/areas.json" "${area_id}" <<'PY' +import json +import sys + +path, area_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) +items = payload["data"]["items"] +if not items: + raise SystemExit("Demo workflow did not expose any project areas") +matches = [item for item in items if item["id"] == area_id] +if not matches: + raise SystemExit(f"Demo area {area_id} was not returned by the area list") +geometry = matches[0].get("geometry") +if not geometry or geometry.get("type") not in {"Polygon", "MultiPolygon"}: + raise SystemExit("Demo area response does not include GeoJSON Polygon/MultiPolygon geometry") +PY + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets" > "${TMP_DIR}/datasets.json" +require_json_data "${TMP_DIR}/datasets.json" +"${PYTHON_BIN}" - "${TMP_DIR}/datasets.json" "${candidate_dataset_id}" "${reference_dataset_id}" <<'PY' +import json +import sys + +path, candidate_id, reference_id = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) +items = payload["data"]["items"] +ids = {item["id"]: item for item in items} +if candidate_id not in ids: + raise SystemExit("Candidate fixture dataset is missing from project dataset list") +if reference_id not in ids: + raise SystemExit("Reference fixture dataset is missing from project dataset list") +if ids[candidate_id]["dataset_type"] not in {"vector", "geojson"}: + raise SystemExit("Candidate fixture dataset is not vector-like") +if ids[reference_id].get("dataset_role") != "reference": + raise SystemExit("Reference fixture dataset is not marked as reference") +PY + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/content" > "${TMP_DIR}/candidate_content.json" +if ! grep -q '"FeatureCollection"' "${TMP_DIR}/candidate_content.json"; then + echo "Candidate dataset content is not a FeatureCollection" >&2 + exit 1 +fi + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/vector/summary" > "${TMP_DIR}/candidate_summary.json" +require_json_data "${TMP_DIR}/candidate_summary.json" +"${PYTHON_BIN}" - "${TMP_DIR}/candidate_summary.json" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as handle: + payload = json.load(handle) +feature_count = payload["data"].get("feature_count") or 0 +if feature_count < 1: + raise SystemExit("Candidate vector summary does not report persisted features") +PY curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/quality-checks" > "${TMP_DIR}/quality_checks.json" require_json_data "${TMP_DIR}/quality_checks.json" @@ -80,6 +146,23 @@ if [ "${quality_count}" -lt 1 ]; then echo "Expected at least one persisted QA/QC result after demo workflow" >&2 exit 1 fi +"${PYTHON_BIN}" - "${TMP_DIR}/quality_checks.json" "${quality_check_id}" <<'PY' +import json +import sys + +path, quality_check_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) +items = payload["data"]["items"] +matches = [item for item in items if item["id"] == quality_check_id] +if not matches: + raise SystemExit("Seeded quality_check_id is not listed in project QA/QC results") +metric_keys = {metric["metric_key"] for metric in matches[0].get("metrics", [])} +required = {"precision", "recall", "f1", "mean_iou", "false_positive_count", "false_negative_count"} +missing = required - metric_keys +if missing: + raise SystemExit(f"QA/QC result is missing metrics: {sorted(missing)}") +PY curl -fsS -X POST "${BASE_URL%/}/api/v1/exports/metadata" \ -H "Content-Type: application/json" \ @@ -128,4 +211,6 @@ fi echo "Demo/export workflow verification passed" echo "Project: ${project_id}" +echo "Area: ${area_id}" +echo "Datasets: candidate=${candidate_dataset_id}, reference=${reference_dataset_id}" echo "Exports created: metadata=${metadata_export_id}, report=${report_export_id}, dataset=${dataset_export_id}"