diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2b822c..a32e1518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 98 demo raster workflow smoke (2026-06-23) + +- Added `scripts/verify_demo_raster_workflow.sh` to validate the browser-facing demo raster happy path: inspect, preview, stats and tile manifest generation. +- Added readiness syntax coverage for the raster workflow smoke. +- Fixed raster tiling manifest generation for Rasterio versions that return window bounds as tuples instead of bound objects. +- Added regression coverage for tuple-based raster window bounds and the new raster smoke contract. +- No AI inference, external provider fetching, migrations or API route changes were introduced. + ## Sprint 97 demo raster fixture workflow (2026-06-23) - Added a deterministic local GeoTIFF raster fixture to the offline demo workflow so raster controls and AI Lab dataset prerequisites have usable V1 context. diff --git a/backend/app/services/raster_operations_service.py b/backend/app/services/raster_operations_service.py index aabd03c6..43a3b2b9 100644 --- a/backend/app/services/raster_operations_service.py +++ b/backend/app/services/raster_operations_service.py @@ -199,6 +199,13 @@ class RasterOperationsService: preview_width = 1 return preview_width, preview_height + @staticmethod + def _window_bounds_to_list(bounds: Any) -> list[float]: + if isinstance(bounds, (list, tuple)) and len(bounds) == 4: + left, bottom, right, top = bounds + return [float(left), float(bottom), float(right), float(top)] + return [float(bounds.left), float(bounds.bottom), float(bounds.right), float(bounds.top)] + @staticmethod def _normalize_preview_data(data: Any) -> Any: try: @@ -972,7 +979,7 @@ class RasterOperationsService: { "path": str(tile_path), "pixel_window": [int(xoff), int(yoff), int(tile_width), int(tile_height)], - "bounds": [float(bounds.left), float(bounds.bottom), float(bounds.right), float(bounds.top)], + "bounds": RasterOperationsService._window_bounds_to_list(bounds), "transform": [float(item) for item in transform.to_gdal()], "index": tile_index, }, diff --git a/backend/tests/test_raster_operations_service.py b/backend/tests/test_raster_operations_service.py index 52ecffe7..3d2a7777 100644 --- a/backend/tests/test_raster_operations_service.py +++ b/backend/tests/test_raster_operations_service.py @@ -599,7 +599,12 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None: @staticmethod def bounds(window: FakeWindow, _source_transform): - return FakeWindowBounds(window.xoff, window.yoff, window.width, window.height) + return ( + float(window.xoff), + float(window.yoff), + float(window.xoff + window.width), + float(window.yoff + window.height), + ) class FakeSource: width = 10 @@ -666,6 +671,7 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None: assert payload["manifest"]["source_dataset_id"] == str(dataset_id) assert payload["manifest"]["source_raster_id"] == str(dataset_id) assert payload["manifest"]["count"] == payload["count"] + assert payload["manifest"]["tiles"][0]["bounds"] == [0.0, 0.0, 4.0, 4.0] assert payload["manifest"]["ai_inference"] is False assert payload["manifest"]["tile_server"] is None diff --git a/backend/tests/test_sprint98_raster_workflow_smoke.py b/backend/tests/test_sprint98_raster_workflow_smoke.py new file mode 100644 index 00000000..e046cb2d --- /dev/null +++ b/backend/tests/test_sprint98_raster_workflow_smoke.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_demo_raster_workflow_smoke_is_registered_and_checks_core_raster_path() -> None: + script_path = ROOT / "scripts" / "verify_demo_raster_workflow.sh" + readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") + + assert script_path.exists() + script = script_path.read_text(encoding="utf-8") + + assert "bash -n scripts/verify_demo_raster_workflow.sh" in readiness + assert "/api/v1/demo/workflow" in script + assert "data.raster_dataset_id" in script + assert "/raster/inspect" in script + assert "/raster/preview" in script + assert "/raster/stats" in script + assert "/raster/tile" in script + assert "demo_context_raster.tif" in script + assert "EPSG:4326" in script + assert "manifest_path" in script + assert "tile_paths" in script + assert "raster.tile" in script + assert "Response is not a canonical GeoIntel data envelope" in script diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index a5502f26..d3d8c4e9 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,37 @@ +## Sprint 98 demo raster workflow smoke (2026-06-23) + +Changed: +- Added `scripts/verify_demo_raster_workflow.sh`, a browser-facing runtime smoke for the seeded `demo_context_raster.tif` fixture. +- Added the new smoke to readiness syntax checks. +- Hardened `RasterOperationsService.tile` so tile manifest bounds accept both Rasterio tuple bounds and object bounds. +- Updated `backend/tests/test_raster_operations_service.py` to cover tuple-based window bounds. +- Added `backend/tests/test_sprint98_raster_workflow_smoke.py`. +- Updated `scripts/README.md` and `CHANGELOG.md`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint98_raster_workflow_smoke.py -q` failed before implementation because `scripts/verify_demo_raster_workflow.sh` did not exist. +- `python -m pytest backend\tests\test_sprint98_raster_workflow_smoke.py backend\tests\test_readiness_gate.py -q` passed: 14 tests. +- `bash -n scripts/verify_demo_raster_workflow.sh` passed. +- Live diagnostic run of `bash scripts/verify_demo_raster_workflow.sh http://192.168.10.150:1202` showed inspect, preview and stats passed, then raster tile failed with HTTP 500. +- Tower backend logs identified the root cause: `rasterio.windows.bounds(...)` returned a tuple, while tile manifest generation expected `.left/.bottom/.right/.top` attributes. +- RED: `python -m pytest backend\tests\test_raster_operations_service.py::test_raster_tile_returns_manifest_payload -q` reproduced the live `AttributeError` after updating the fixture to tuple bounds. +- `python -m pytest backend\tests\test_raster_operations_service.py::test_raster_tile_returns_manifest_payload backend\tests\test_sprint98_raster_workflow_smoke.py -q` passed: 2 tests. +- `python -m compileall backend/app` passed. +- `python -m pytest -q` from `backend/` passed: 321 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. +- `bash scripts/run_readiness_check.sh` passed. +- `cd backend && python -m alembic heads` passed: `202606120900 (head)`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` passed. + +Limitations: +- The raster smoke creates a small tile set each run. It is appropriate for local/runtime validation, not high-volume benchmarking. +- The smoke intentionally does not run AI inference or external imagery/provider fetching. + +Next recommended pass: +- Run full readiness, redeploy, then verify the raster smoke live against `http://192.168.10.150:1202`. + ## Sprint 97 demo raster fixture workflow (2026-06-23) Changed: diff --git a/scripts/README.md b/scripts/README.md index f972f460..bd00187a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -37,6 +37,18 @@ persisted QA/QC result is compared against `fixtures/golden/expected_qa_metrics. so runtime demo precision, recall, F1, mean IoU and false-positive/negative counts cannot drift silently. +Verify the explicit demo raster workflow: + +```bash +bash scripts/verify_demo_raster_workflow.sh http://192.168.10.150:1202 +``` + +The raster smoke is intentionally mutating and idempotent enough for local +runtime checks: it seeds the offline demo workflow, validates the +`demo_context_raster.tif` fixture dataset, then exercises raster inspect, +preview, stats and one small tile/manifest generation through canonical +`data` envelopes. It does not run AI inference or fetch external imagery. + Verify that the browser-facing workbench can populate the default demo start state through the frontend proxy: diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 9d4bdd71..10566aac 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -51,6 +51,7 @@ ${PYTHON_BIN} -m compileall backend/app bash -n scripts/live_migration_smoke.sh bash -n scripts/verify_browser_runtime.sh bash -n scripts/verify_demo_export_workflow.sh +bash -n scripts/verify_demo_raster_workflow.sh bash -n scripts/verify_workbench_default_state.sh bash -n scripts/verify_workbench_interactions.sh bash -n scripts/verify_gis_runtime.sh diff --git a/scripts/verify_demo_raster_workflow.sh b/scripts/verify_demo_raster_workflow.sh new file mode 100644 index 00000000..53542199 --- /dev/null +++ b/scripts/verify_demo_raster_workflow.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:1202}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required for demo raster workflow verification" >&2 + exit 1 +fi + +if [ -n "${PYTHON_BIN:-}" ]; then + PYTHON_BIN="${PYTHON_BIN}" +else + PYTHON_BIN="" + for candidate in python3 python.exe python; do + if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import json, sys" >/dev/null 2>&1; then + PYTHON_BIN="${candidate}" + break + fi + done +fi + +if [ -z "${PYTHON_BIN}" ]; then + echo "A Python interpreter is required for JSON parsing" >&2 + exit 1 +fi + +json_field() { + local file_path="$1" + local expression="$2" + "${PYTHON_BIN}" - "$file_path" "$expression" <<'PY' +import json +import sys + +path, expression = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) +value = payload +for part in expression.split("."): + if part: + value = value[part] +print(value) +PY +} + +require_json_data() { + local file_path="$1" + "${PYTHON_BIN}" - "$file_path" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as handle: + payload = json.load(handle) +if "data" not in payload: + raise SystemExit("Response is not a canonical GeoIntel data envelope") +PY +} + +echo "== GeoIntel demo raster workflow verification ==" +echo "Base URL: ${BASE_URL}" + +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")" +raster_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.raster_dataset_id")" + +if [ -z "${raster_dataset_id}" ] || [ "${raster_dataset_id}" = "None" ] || [ "${raster_dataset_id}" = "null" ]; then + echo "Demo workflow did not return a raster_dataset_id" >&2 + exit 1 +fi + +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" "${raster_dataset_id}" <<'PY' +import json +import sys + +path, raster_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + items = json.load(handle)["data"]["items"] +raster = next((item for item in items if item["id"] == raster_id), None) +if not raster: + raise SystemExit("Demo raster dataset is missing from dataset list") +if raster.get("name") != "demo_context_raster.tif": + raise SystemExit(f"Unexpected demo raster name: {raster.get('name')}") +if raster.get("dataset_type") != "raster": + raise SystemExit(f"Unexpected demo raster dataset_type: {raster.get('dataset_type')}") +if raster.get("source_name") != "fixture": + raise SystemExit(f"Unexpected demo raster source_name: {raster.get('source_name')}") +if raster.get("status") != "ready": + raise SystemExit(f"Unexpected demo raster status: {raster.get('status')}") +if raster.get("crs") != "EPSG:4326": + raise SystemExit(f"Unexpected demo raster CRS: {raster.get('crs')}") +PY + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/inspect" > "${TMP_DIR}/inspect.json" +require_json_data "${TMP_DIR}/inspect.json" +"${PYTHON_BIN}" - "${TMP_DIR}/inspect.json" "${raster_dataset_id}" <<'PY' +import json +import sys + +path, raster_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle)["data"] +if data.get("dataset_id") != raster_id: + raise SystemExit("Raster inspect returned the wrong dataset_id") +if data.get("ready") is not True: + raise SystemExit("Raster inspect did not report ready=true") +metadata = data.get("metadata") or {} +if metadata.get("crs") != "EPSG:4326": + raise SystemExit(f"Raster inspect metadata CRS drifted: {metadata.get('crs')}") +if int(metadata.get("width") or 0) < 1 or int(metadata.get("height") or 0) < 1: + raise SystemExit("Raster inspect metadata does not expose width/height") +PY + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/preview" > "${TMP_DIR}/preview.json" +require_json_data "${TMP_DIR}/preview.json" +"${PYTHON_BIN}" - "${TMP_DIR}/preview.json" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as handle: + data = json.load(handle)["data"] +if data.get("ready") is not True: + raise SystemExit("Raster preview did not report ready=true") +preview = data.get("preview") or {} +if str(preview.get("format") or "").lower() != "png": + raise SystemExit(f"Unexpected preview format: {preview.get('format')}") +if int(preview.get("width") or 0) < 1 or int(preview.get("height") or 0) < 1: + raise SystemExit("Raster preview did not expose image dimensions") +PY + +curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/stats" > "${TMP_DIR}/stats.json" +require_json_data "${TMP_DIR}/stats.json" +"${PYTHON_BIN}" - "${TMP_DIR}/stats.json" "${raster_dataset_id}" <<'PY' +import json +import sys + +path, raster_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle)["data"] +if data.get("dataset_id") != raster_id: + raise SystemExit("Raster stats returned the wrong dataset_id") +bands = data.get("bands") or [] +if not bands: + raise SystemExit("Raster stats did not report band statistics") +band = bands[0] +if band.get("valid_pixel_count", 0) < 1: + raise SystemExit("Raster stats valid_pixel_count is empty") +if band.get("min") is None or band.get("max") is None: + raise SystemExit("Raster stats min/max are missing") +PY + +curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/tile" \ + -H "Content-Type: application/json" \ + -d '{"tile_size":64,"overlap":0,"output_name":"demo_raster_smoke_tiles"}' > "${TMP_DIR}/tile.json" +require_json_data "${TMP_DIR}/tile.json" +"${PYTHON_BIN}" - "${TMP_DIR}/tile.json" "${raster_dataset_id}" <<'PY' +import json +import sys + +path, raster_id = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as handle: + job = json.load(handle)["data"] +if job.get("job_type") != "raster.tile": + raise SystemExit(f"Unexpected tile job_type: {job.get('job_type')}") +if job.get("status") != "completed": + raise SystemExit(f"Raster tile job did not complete: {job.get('status')}") +result = job.get("result_json") or {} +if result.get("dataset_id") != raster_id: + raise SystemExit("Raster tile result returned the wrong dataset_id") +if result.get("ready") is not True: + raise SystemExit("Raster tile result did not report ready=true") +if result.get("operation") != "raster.tile": + raise SystemExit(f"Unexpected raster tile operation: {result.get('operation')}") +if not result.get("manifest_path"): + raise SystemExit("Raster tile result is missing manifest_path") +manifest = result.get("manifest") or {} +tile_paths = manifest.get("tile_paths") or [] +tiles = manifest.get("tiles") or [] +if result.get("count") != len(tile_paths) or len(tile_paths) != len(tiles): + raise SystemExit("Raster tile manifest count does not match tile_paths/tiles") +if not tile_paths: + raise SystemExit("Raster tile manifest did not include any tile paths") +if manifest.get("source_dataset_id") != raster_id: + raise SystemExit("Raster tile manifest source_dataset_id drifted") +PY + +echo "Demo raster workflow verification passed" +echo "Project: ${project_id}" +echo "Raster dataset: ${raster_dataset_id}" +echo "Operations: inspect, preview, stats and raster.tile"