From 2456ecb83458befb93225045f3777617f19a05f9 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 09:24:39 +0000 Subject: [PATCH 1/3] feat(orchestrator): dev-only scripted local outcomes for stage stubs HSS_LOCAL_OUTCOME walks a stubbed local run to each state the app renders: manual_review (default, today's bytes), eligible, needs_more_photos, not_eligible (needs SHOW_REJECT_TO_HOMEOWNER=1), failed (validate fails onto outcome row 1). HSS_LOCAL_STAGE_DELAY_S paces stub stages 0-30s. Both are refused loudly outside dev stage stubs; scripted messages and the review page say the outcome is a local test, not a check of a wall. --- backend/server/orchestrator/README.md | 16 +- backend/server/orchestrator/config.py | 51 ++ backend/server/orchestrator/stubs/_common.py | 7 + backend/server/orchestrator/stubs/criteria.py | 21 +- backend/server/orchestrator/stubs/result.py | 212 +++++++- backend/server/orchestrator/stubs/validate.py | 40 +- backend/tests/test_local_outcome_states.py | 462 ++++++++++++++++++ 7 files changed, 784 insertions(+), 25 deletions(-) create mode 100644 backend/tests/test_local_outcome_states.py diff --git a/backend/server/orchestrator/README.md b/backend/server/orchestrator/README.md index 6a7bea018..d6c04b762 100644 --- a/backend/server/orchestrator/README.md +++ b/backend/server/orchestrator/README.md @@ -33,4 +33,18 @@ make ci ## Stubs -`HSS_ORCH_STAGE_STUBS=1` runs `server/orchestrator/stubs/.py` instead of `server..run`. Stub verdicts are `manual_review` with empty `views_needed`. +`HSS_ORCH_STAGE_STUBS=1` runs `server/orchestrator/stubs/.py` instead of `server..run`. Stub verdicts are `manual_review` with empty `views_needed`. The stub review page says every stage is a stand-in. + +### Scripted local outcomes (dev only) + +With stage stubs on and `SMP_ENVIRONMENT=dev`, `HSS_LOCAL_OUTCOME` scripts the terminal state a stubbed local run reaches — the app can be walked to each state it renders, end to end, without Modal or GPU: + +| `HSS_LOCAL_OUTCOME` | Capture status | Notes | +| --- | --- | --- | +| unset / `manual_review` (default) | `manual_review` | Today's stub run, byte-identical. | +| `eligible` | `complete` | Result carries a proposed placement (`boxArkitWorld` + `arkitEpoch` when the packet has one), worded as proposed. | +| `needs_more_photos` | `needs_views` | One scripted band view; the app sends a retake request. | +| `not_eligible` | `complete` | Needs `SHOW_REJECT_TO_HOMEOWNER=1`; without it the boot refuses. | +| `failed` | `failed` | The validate stub fails the run onto outcome row 1 (validation stand-in `imagesUsable: false`). | + +Scripted messages and review pages always say it is a local test outcome, not a check of a wall. `HSS_LOCAL_STAGE_DELAY_S` (float, 0–30, default 0) makes each stub sleep so the app shows processing events. Both variables are refused loudly — at boot with an error naming the variable — when stage stubs are off, the environment is not dev, the value is unknown, or the number is out of range. A run keeps its scripted behavior across restart while the variables stay set. diff --git a/backend/server/orchestrator/config.py b/backend/server/orchestrator/config.py index 28cbfd3aa..4b5a55d02 100644 --- a/backend/server/orchestrator/config.py +++ b/backend/server/orchestrator/config.py @@ -11,6 +11,55 @@ def _flag(value: str) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +_LOCAL_OUTCOMES = ( + "manual_review", + "eligible", + "needs_more_photos", + "not_eligible", + "failed", +) + + +def _dev_stubs(env: dict[str, str]) -> bool: + """Scripted local outcomes are honored only under dev stage stubs.""" + return _flag(env.get("HSS_ORCH_STAGE_STUBS", "0")) and env.get("SMP_ENVIRONMENT", "prod") == "dev" + + +def _require_dev_stubs(name: str, env: dict[str, str]) -> None: + if _dev_stubs(env): + return + raise ValueError( + f"{name} is only honored with HSS_ORCH_STAGE_STUBS=1 and SMP_ENVIRONMENT=dev " + f"(got HSS_ORCH_STAGE_STUBS={env.get('HSS_ORCH_STAGE_STUBS', '0')!r}, " + f"SMP_ENVIRONMENT={env.get('SMP_ENVIRONMENT', '(unset)')!r}); " + "refusing to run with it silently ignored" + ) + + +def _local_outcome(env: dict[str, str]) -> str: + raw = env.get("HSS_LOCAL_OUTCOME", "") + if not raw: + return "manual_review" + if raw not in _LOCAL_OUTCOMES: + raise ValueError(f"HSS_LOCAL_OUTCOME={raw!r} is not one of {', '.join(_LOCAL_OUTCOMES)}") + _require_dev_stubs("HSS_LOCAL_OUTCOME", env) + return raw + + +def _local_stage_delay_s(env: dict[str, str]) -> float: + raw = env.get("HSS_LOCAL_STAGE_DELAY_S", "") + if not raw: + return 0.0 + try: + value = float(raw) + except ValueError: + raise ValueError(f"HSS_LOCAL_STAGE_DELAY_S={raw!r} is not a number of seconds") from None + if not 0 <= value <= 30: + raise ValueError(f"HSS_LOCAL_STAGE_DELAY_S={raw!r} is outside the range 0 to 30 seconds") + _require_dev_stubs("HSS_LOCAL_STAGE_DELAY_S", env) + return value + + class OrchConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -63,4 +112,6 @@ def pick(name: str, default: str) -> str: test_kill_coordinator_after=pick("HSS_ORCH_TEST_KILL_COORDINATOR_AFTER", ""), coordinator_loop_s=float(pick("HSS_ORCH_COORDINATOR_LOOP_S", "1")), modal_remote=_flag(pick("HSS_ORCH_MODAL_REMOTE", "0")), + local_outcome=_local_outcome(env), + local_stage_delay_s=_local_stage_delay_s(env), ) diff --git a/backend/server/orchestrator/stubs/_common.py b/backend/server/orchestrator/stubs/_common.py index 7a4dff27b..fd14344d1 100644 --- a/backend/server/orchestrator/stubs/_common.py +++ b/backend/server/orchestrator/stubs/_common.py @@ -5,16 +5,23 @@ import hashlib import io import json +import time from pathlib import Path import numpy as np from server.contracts.jobs import StageContext, StageResult +from server.orchestrator.config import load_orch _PLY = b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\n0 0 0\n" def write_files(ctx: StageContext, files: dict[str, bytes]) -> StageResult: + # The dev-only scripted delay: each stub sleeps so the app sees the stage + # move through processing. load_orch refuses a stray value at boot. + delay = load_orch().local_stage_delay_s + if delay > 0: + time.sleep(delay) artifacts: dict[str, str] = {} for key, data in files.items(): path = ctx.work_dir / key diff --git a/backend/server/orchestrator/stubs/criteria.py b/backend/server/orchestrator/stubs/criteria.py index a1d4c7297..be196409f 100644 --- a/backend/server/orchestrator/stubs/criteria.py +++ b/backend/server/orchestrator/stubs/criteria.py @@ -1,16 +1,31 @@ -"""Stub ``criteria`` stage. Decisions stay ``manual_review`` so a stub cannot be an unsafe pass.""" +"""Stub ``criteria`` stage. Decisions stay ``manual_review`` so a stub cannot be an unsafe pass. + +A scripted local outcome (``HSS_LOCAL_OUTCOME``, dev + stage stubs only) is the +one exception: it writes the decision the outcome table's rows 11-14 need to +land the capture on the chosen state. Nothing else changes. +""" from __future__ import annotations from server.contracts.jobs import StageContext, StageResult from server.eval.sceneio import schema_errors -from server.orchestrator.stubs._common import json_bytes, verdict_document, write_files +from server.orchestrator.stubs._common import json_bytes, scripted_outcome, scripted_verdict, verdict_document, write_files def run(ctx: StageContext) -> StageResult: + outcome = scripted_outcome() + if outcome == "not_eligible" and not getattr(ctx.settings, "SHOW_REJECT_TO_HOMEOWNER", False): + raise RuntimeError( + "HSS_LOCAL_OUTCOME=not_eligible needs SHOW_REJECT_TO_HOMEOWNER=1; " + "with the reject hidden the capture would land manual_review instead" + ) files = {} for profile in ("A", "B", "C", "S"): - verdict = verdict_document(profile, ctx.capture_id) + verdict = ( + verdict_document(profile, ctx.capture_id) + if outcome in ("manual_review", "failed") + else scripted_verdict(profile, ctx.capture_id, outcome) + ) errors = schema_errors(verdict) if errors: raise RuntimeError("artifact_invalid:criteria " + "; ".join(errors[:5])) diff --git a/backend/server/orchestrator/stubs/result.py b/backend/server/orchestrator/stubs/result.py index 856898f18..7916a287f 100644 --- a/backend/server/orchestrator/stubs/result.py +++ b/backend/server/orchestrator/stubs/result.py @@ -1,4 +1,11 @@ -"""Stub ``result`` stage. Emits the one ``verdict_ready`` and does not edit the verdict.""" +"""Stub ``result`` stage. Emits the one ``verdict_ready`` and does not edit the verdict. + +With ``HSS_LOCAL_OUTCOME`` (dev + stage stubs only) the stub scripts the +terminal state instead: the verdict the criteria stub writes lands the capture +on the chosen outcome-table row, and this stage composes the matching device +result. Every scripted message says it is a local test outcome, not a check of +a wall. The review page is honest for both modes: every stage is a stand-in. +""" from __future__ import annotations @@ -6,55 +13,222 @@ from pathlib import Path from typing import Literal -from server.contracts.jobs import Profile, StageContext, StageResult -from server.contracts.result import MeterBlock, Outcome, ResultResponse -from server.orchestrator.stubs._common import json_bytes, sha256, write_files +import numpy as np + +from server.contracts.jobs import STAGE_DEPS, Profile, StageContext, StageResult +from server.contracts.result import ( + BoxArkitWorld, + BoxSceneM, + FootprintM, + MeterBlock, + Outcome, + RecommendedPlacement, + ResultResponse, + ViewNeededDevice, +) +from server.orchestrator.config import load_orch +from server.orchestrator.stubs._common import json_bytes, scripted_outcome, sha256, write_files +from server.result import ar +from server.result.copy import FALLBACK, Catalogue, load_catalogue, render, select_prompt _PROFILES: frozenset[str] = frozenset({"A", "B", "C", "S"}) +# Outcome kind to capture status, exactly the real composer's map. +_STATUS = { + "eligible": "complete", + "needs_more_photos": "needs_views", + "not_eligible": "complete", + "manual_review": "manual_review", +} + +_MESSAGE = { + "eligible": ( + "Local test stand-in: the proposed placement is scripted, not measured. " + "This is a local test outcome, not a check of a wall." + ), + "needs_more_photos": ( + "Local test stand-in: the retake request is scripted, not measured. " + "This is a local test outcome, not a check of a wall." + ), + "not_eligible": ( + "Local test stand-in: the not-eligible decision is scripted, not measured. " + "This is a local test outcome, not a check of a wall." + ), +} + +# The profile-C footprint, as the real composer's FOOTPRINT table holds it. +_FOOTPRINT_C = (0.7874, 1.0033, 0.5588) +_STANDOFF_M = 0.02 + +# The committed prompt catalogue; the production catalogue may not exist here. +_PROMPTS = Path(__file__).resolve().parents[2] / "result" / "tests" / "data" / "prompts.en-US.json" + def run(ctx: StageContext) -> StageResult: + outcome = scripted_outcome() + if outcome == "failed": + raise RuntimeError( + "HSS_LOCAL_OUTCOME=failed must end the run at validate; the result stage should not run" + ) + if outcome == "not_eligible" and not getattr(ctx.settings, "SHOW_REJECT_TO_HOMEOWNER", False): + raise RuntimeError( + "HSS_LOCAL_OUTCOME=not_eligible needs SHOW_REJECT_TO_HOMEOWNER=1; " + "with the reject hidden the capture would land manual_review instead" + ) run_rec = ctx.state.get_run(ctx.run_id) key = run_rec.artifacts["verdicts/verdict.C.json"] verdict_bytes = ctx.storage.get_bytes(key) digest = sha256(verdict_bytes) verdict = json.loads(verdict_bytes) decision = verdict["site"]["decision"] - kind = "manual_review" if decision == "manual_review" else decision + kind = "manual_review" if outcome == "manual_review" else outcome ctx.state.append_event( ctx.capture_id, "verdict_ready", {"runId": ctx.run_id, "kind": kind}, run_id=ctx.run_id, ) - response = ResultResponse( - runId=ctx.run_id, - status="manual_review", - viewsNeeded=[], - memberActions=[], - outcome=Outcome( - kind="manual_review", - profile=_profile(ctx), - message="Review required before a placement is shown.", + if outcome == "manual_review": + # The default run: today's bytes, unchanged. + response = ResultResponse( + runId=ctx.run_id, + status="manual_review", viewsNeeded=[], - reasons=[], - ), - meter=_stub_meter(ctx), - ) + memberActions=[], + outcome=Outcome( + kind="manual_review", + profile=_profile(ctx), + message="Review required before a placement is shown.", + viewsNeeded=[], + reasons=[], + ), + meter=_stub_meter(ctx), + ) + else: + views = _views_needed() if outcome == "needs_more_photos" else [] + epoch = _packet_epoch(ctx) + response = ResultResponse( + runId=ctx.run_id, + status=_STATUS[outcome], + viewsNeeded=list(views), + memberActions=[], + outcome=Outcome( + kind=kind, # type: ignore[arg-type] + profile=_profile(ctx), + message=_MESSAGE[outcome], + viewsNeeded=list(views), + reasons=[], + arkitEpoch=epoch, + recommendedPlacement=_placement(_profile(ctx), epoch) if outcome == "eligible" else None, + ), + meter=_stub_meter(ctx), + ) body = response.model_dump(mode="json") ResultResponse.model_validate(body) internal = {"verdict_c_sha256": digest, "decision": decision} + if outcome != "manual_review": + internal["scripted_outcome"] = outcome return write_files( ctx, { "result/result.json": json_bytes(body), "result/result.internal.json": json_bytes(internal), - "result/review.html": b"stub review", + "result/review.html": _review_page(outcome), "result/preview.ply": b"ply\nformat ascii 1.0\nelement vertex 0\nend_header\n", }, ) +def _placement(profile: str, epoch: str | None) -> RecommendedPlacement: + """A proposed spot on the stub wall, in ARKit world when the packet has an epoch. + + The stub scene is meter-anchored with scale 1.0, so the scene-to-ARKit + transform is the identity: the box pose is the meter-frame box pose, and + the epoch comes from the capture packet's own ``epochs`` list. + """ + width, height, depth = _FOOTPRINT_C + size = [width, height, depth] + center = [0.6937, height / 2, depth / 2 + _STANDOFF_M] + box_arkit = None + if epoch is not None: + built = ar.box_to_arkit(np.eye(4), center, size, 0.0) + box_arkit = BoxArkitWorld(pose=built["pose"], size=built["size"]) + return RecommendedPlacement( + wallId="w1", + startSM=0.3, + footprintM=FootprintM(w=width, h=height, d=depth), + boxSceneM=BoxSceneM(centerM=center, sizeM=size, yawDeg=0.0), + confidence=0.5, + profile=profile, # type: ignore[arg-type] + boxArkitWorld=box_arkit, + ) + + +def _packet_epoch(ctx: StageContext) -> str | None: + """The packet's ARKit epoch, when the capture carries exactly one. + + Same rule as ``server.result.ar.epoch_id``: the stub scene frame names no + ``source_frame``, so a single packet epoch is the epoch; anything else is + None and the placement stays in scene metres. + """ + epochs = _packet_epochs(ctx) + if len(set(epochs)) == 1: + return epochs[0] + return None + + +def _packet_epochs(ctx: StageContext) -> list[str]: + try: + raw = (Path(str(ctx.inputs["packet_dir"])) / "packet.json").read_text() + packet = json.loads(raw) + except (KeyError, OSError, json.JSONDecodeError, TypeError): + return [] + epochs = packet.get("epochs") if isinstance(packet, dict) else None + if not isinstance(epochs, list): + return [] + return [str(item) for item in epochs if isinstance(item, str)] + + +def _views_needed() -> list[ViewNeededDevice]: + """One scripted band view, prompted from the real catalogue with a real id.""" + catalogue = _catalogue() + entry = select_prompt(catalogue, kind="band", band="wall", cause="unobserved") + prompt = render(entry, side="left", s_ft=2.0, out_ft=10.0) + return [ + ViewNeededDevice( + id="stub-wall-band", + kind="band", + criteria=["stub"], + why="Local test stand-in: this retake request is scripted, not measured.", + promptId=prompt.promptId, + prompt={"title": prompt.title, "body": prompt.body}, + band="wall", + wallId="w1", + sM=[0.2, 1.0], + side="left", + viewingAngleDeg=[0, 30], + ) + ] + + +def _catalogue() -> Catalogue: + try: + return load_catalogue(_PROMPTS) + except (OSError, FileNotFoundError): + return FALLBACK + + +def _review_page(outcome: str) -> bytes: + stages = ", ".join(STAGE_DEPS) + return ( + "

Local stage-stub run

" + f"

Every stage here is a stand-in: stage stubs ran instead of real processing ({stages}).

" + f"

Scripted outcome: {outcome} (HSS_LOCAL_OUTCOME). " + "This is a local test, not a check of a wall.

" + "" + ).encode() + + def _profile(ctx: StageContext) -> Profile: configured = getattr(ctx.settings, "RULES_PROFILE_DEFAULT", "C") if configured in _PROFILES: diff --git a/backend/server/orchestrator/stubs/validate.py b/backend/server/orchestrator/stubs/validate.py index 47fa2d0b8..d50851d3b 100644 --- a/backend/server/orchestrator/stubs/validate.py +++ b/backend/server/orchestrator/stubs/validate.py @@ -1,12 +1,20 @@ -"""Stub ``validate`` stage.""" +"""Stub ``validate`` stage. + +A scripted ``HSS_LOCAL_OUTCOME=failed`` (dev + stage stubs only) ends the run +here, on outcome row 1: the stage fails while its validation stand-in reports +unusable images. A failed stage never publishes artifacts, so the stand-in +registers its own artifact before raising. +""" from __future__ import annotations from server.contracts.jobs import StageContext, StageResult -from server.orchestrator.stubs._common import json_bytes, write_files +from server.orchestrator.stubs._common import json_bytes, scripted_outcome, write_files def run(ctx: StageContext) -> StageResult: + if scripted_outcome() == "failed": + _script_failed(ctx) return write_files( ctx, { @@ -15,3 +23,31 @@ def run(ctx: StageContext) -> StageResult: "packet/capture_check.json": json_bytes({"complete": False, "missing": ["meter_close", "meter_oblique"]}), }, ) + + +def _script_failed(ctx: StageContext) -> None: + """Publish the validation stand-in, then fail the stage. + + Row 1 of ``server/orchestrator/outcomes.py`` is the one row whose capture + status is ``failed``: the validate stage failed while + ``packet/validation.json`` says the images are unusable. The stand-in + registers itself on the run record directly, because a failing stage's + ``StageResult`` is discarded. + """ + key = "packet/validation.json" + body = json_bytes( + { + "imagesUsable": False, + "ok": False, + "issues": ["scripted_local_test"], + "reason": "HSS_LOCAL_OUTCOME=failed", + } + ) + ctx.storage.put_bytes(key, body, "application/json") + run = ctx.state.get_run(ctx.run_id) + run.artifacts[key] = key + ctx.state.put_run(run) + raise RuntimeError( + "local_scripted_failure: HSS_LOCAL_OUTCOME=failed ends the run on outcome row 1 " + "(validate failed, images unusable)" + ) diff --git a/backend/tests/test_local_outcome_states.py b/backend/tests/test_local_outcome_states.py new file mode 100644 index 000000000..b7b595ba3 --- /dev/null +++ b/backend/tests/test_local_outcome_states.py @@ -0,0 +1,462 @@ +"""``HSS_LOCAL_OUTCOME`` walks a stubbed local run to each state the app renders. + +Every capture status the app renders — ``manual_review``, ``complete``, +``needs_views``, ``failed`` — has an outcome-table row behind it +(``server/orchestrator/outcomes.py``), and until now no local run could reach +any of them but ``manual_review``: the stage stubs always decided +``manual_review``, so "walk the app locally" stopped at one screen. This packet +adds a dev-only script: with stage stubs and the dev environment, setting +``HSS_LOCAL_OUTCOME`` (or ``HSS_LOCAL_STAGE_DELAY_S`` for pacing) drives one +run onto each row. + +These tests are that proof, over the same documented dev settings as the +journey tests — memory state, volume storage, static test auth, stage stubs, +no Modal, no GPU, no network. The committed fixtures under +``tests/fixtures/app_states//`` are the served device payloads and +event walks, one per state, regenerated with +``uv run python tests/test_local_outcome_states.py --generate`` and re-checked +here so a later contract change cannot silently invalidate the evidence. + +The scripted outcomes are test stand-ins: every message and review page says so +("local test outcome, not a check of a wall"), and the default stub run keeps +today's bytes. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import shutil +import tempfile +import time +from pathlib import Path +from typing import Callable + +import pytest +from fastapi.testclient import TestClient + +from server.api.app import create_app +from server.api.deps import drain_finalize +from server.common.config import Settings +from server.contracts.result import ResultResponse +from server.orchestrator.config import load_orch +from server.orchestrator.outcomes import capture_status + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKET_FIXTURE = REPO_ROOT / "backend" / "data" / "fixtures" / "packets" / "mini-arkit" +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "app_states" + +# (name, HSS_LOCAL_OUTCOME, SHOW_REJECT_TO_HOMEOWNER, capture status, outcome kind). +STATES = [ + ("manual_review", "manual_review", False, "manual_review", "manual_review"), + ("eligible", "eligible", False, "complete", "eligible"), + ("needs_more_photos", "needs_more_photos", False, "needs_views", "needs_more_photos"), + ("not_eligible", "not_eligible", True, "complete", "not_eligible"), + ("failed", "failed", False, "failed", None), +] + +TEMPLATE_ENV = { + "HSS_ORCH_STAGE_STUBS": "1", + "HSS_ORCH_PREWARM": "0", + "VISION_EXTERNAL_CALLS": "off", + "STATE_BACKEND": "memory", + "STORAGE_BACKEND": "volume", + "SMP_ENVIRONMENT": "dev", +} + + +def _settings(tmp: Path, show_reject: bool) -> Settings: + return Settings( + SMP_ENVIRONMENT="dev", + HSS_AUTH_MODE="static", + # Synthetic, explicitly static test auth — no real principal. + CAPTURE_API_TOKENS=f"device:{'d' * 40}", + UPLOAD_URL_HMAC_SECRET="h" * 32, + STORAGE_BACKEND="volume", + STATE_BACKEND="memory", + VOLUME_ROOT=str(tmp / "vol"), + CAPTURE_API_PUBLIC_URL="http://testserver", + HSS_INGEST_HOOKS="auto", + VISION_EXTERNAL_CALLS="off", + GIT_SHA="0123456789abcdef", + SMP_SERVICE_NAME="capture-api", + SHOW_REJECT_TO_HOMEOWNER=show_reject, + ) + + +@pytest.fixture +def local_app(tmp_path, monkeypatch): + """Build the app behind the capture front door with a scripted outcome. + + Returns a builder ``(outcome, show_reject, delay=None) -> (root, packet, + client, app, deps)``; monkeypatch restores the environment afterwards. + """ + for key, value in TEMPLATE_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("HSS_LOCAL_OUTCOME", raising=False) + monkeypatch.delenv("HSS_LOCAL_STAGE_DELAY_S", raising=False) + + def build(outcome: str | None = None, show_reject: bool = False, delay: float | None = None): + root = tmp_path / f"packet-{outcome or 'default'}" + shutil.copytree(PACKET_FIXTURE, root) + packet = json.loads((root / "packet.json").read_text()) + monkeypatch.setenv("VOLUME_ROOT", str(tmp_path / f"vol-{outcome or 'default'}")) + if outcome is not None: + monkeypatch.setenv("HSS_LOCAL_OUTCOME", outcome) + if delay is not None: + monkeypatch.setenv("HSS_LOCAL_STAGE_DELAY_S", str(delay)) + settings = _settings(tmp_path, show_reject) + app = create_app(settings=settings) + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {'d' * 40}" + return root, packet, client, app, app.state.deps + + return build + + +# --- the capture front door, as the journey tests walk it ------------------- + + +def _content_type(role: str) -> str: + if role == "stream": + return "application/gzip" + if role in {"features", "depth", "confidence", "mesh"}: + return "application/octet-stream" + return "image/jpeg" + + +def _register_and_commit(root: Path, packet: dict, client: TestClient, app, capture_id: str) -> None: + registered_paths = [] + for entry in packet["files"]: + data = (root / entry["path"]).read_bytes() + item = { + "path": entry["path"], + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "md5": base64.b64encode(hashlib.md5(data).digest()).decode(), + "role": entry.get("role", "still"), + "contentType": _content_type(entry.get("role", "still")), + } + registered = client.post(f"/v1/captures/{capture_id}/files", json={"files": [item]}) + assert registered.status_code == 200, registered.text + upload = registered.json()["files"][0]["upload"] + # The signed URL is its own capability: no bearer header on the PUT. + put = TestClient(app).put(upload["url"], content=data, headers=upload.get("headers") or {}) + assert put.status_code == 200, put.text + registered_paths.append({"path": item["path"], "sha256": item["sha256"]}) + commit = client.post(f"/v1/captures/{capture_id}/files:commit", json={"files": registered_paths}) + assert commit.status_code == 200, commit.text + + +def _open_front_door(root: Path, packet: dict, client: TestClient, app, packet_id: str) -> tuple[str, bytes]: + document = dict(packet) + document["packetId"] = packet_id + raw = (json.dumps(document, indent=2) + "\n").encode() + (root / "packet.json").write_bytes(raw) + session = packet["session"] + created = client.post( + "/v1/captures", + json={ + "packetId": packet_id, + "formatVersion": packet["formatVersion"], + "tier": session["tier"], + "flow": "guided", + "device": { + "model": session["deviceModel"], + "systemVersion": session["systemVersion"], + "appVersion": session["appVersion"], + }, + }, + headers={"Idempotency-Key": packet_id}, + ) + assert created.status_code == 201, created.text + capture_id = created.json()["captureId"] + _register_and_commit(root, packet, client, app, capture_id) + return capture_id, raw + + +def _run_capture( + root: Path, packet: dict, client: TestClient, app, packet_id: str +) -> tuple[str, str, object]: + """Front door, finalize, drain; returns (capture_id, run_id, capture record).""" + capture_id, raw = _open_front_door(root, packet, client, app, packet_id) + response = client.post( + f"/v1/captures/{capture_id}/finalize", + content=raw, + headers={ + "Idempotency-Key": packet_id, + "Content-Type": "application/json", + "X-Packet-Sha256": hashlib.sha256(raw).hexdigest(), + }, + ) + assert response.status_code == 202, response.text + drain_finalize(app.state.deps) + run_id = response.json()["runId"] + return capture_id, run_id, app.state.deps.state.get_capture(capture_id) + + +def _flat(client: TestClient, capture_id: str) -> list[dict]: + """Walk the events cursor to quiescence, flattened in delivery order.""" + events: list[dict] = [] + after = 0 + for _ in range(60): + page = client.get(f"/v1/captures/{capture_id}/events", params={"after": after}).json() + assert page["next"] >= after, "the events cursor moved backwards" + events.extend(page["events"]) + if page["next"] == after: + return events + after = page["next"] + pytest.fail("event feed never stopped moving") + + +# --- the states -------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,outcome,show_reject,status,kind", + STATES, + ids=[state[0] for state in STATES], +) +def test_hss_local_outcome_walks_the_app_to_each_state( + name: str, outcome: str, show_reject: bool, status: str, kind: str | None, local_app +) -> None: + root, packet, client, app, deps = local_app(outcome, show_reject) + capture_id, run_id, capture = _run_capture(root, packet, client, app, f"local-{name}") + + assert capture.status == status, f"HSS_LOCAL_OUTCOME={outcome} must land {status}" + served = client.get(f"/v1/captures/{capture_id}/result") + assert served.status_code == 200, served.text + payload = served.json() + # The device envelope stays contract-valid in every state. + ResultResponse.model_validate(payload) + + run = deps.state.get_run(run_id) + review = deps.storage.get_bytes(run.artifacts["result/review.html"]) + assert b"stand-in" in review + if kind is None: + # Row 1: the validate stub failed the run; nothing composes, nothing leaks. + assert payload["outcome"] is None + assert run.status == "failed" + return + + assert payload["status"] == status + assert payload["outcome"]["kind"] == kind + events = _flat(client, capture_id) + final = events[-1] + assert final["type"] == ("retake_request" if outcome == "needs_more_photos" else "verdict_ready") + + # Scripted outcomes always say they are stand-ins, on the device and off it. + assert "local test outcome" in payload["outcome"]["message"] + assert "This is a local test" in payload["outcome"]["message"] + assert b"HSS_LOCAL_OUTCOME" in review + internal = json.loads(deps.storage.get_bytes(run.artifacts["result/result.internal.json"])) + assert internal["scripted_outcome"] == outcome + assert "scripted_outcome" not in json.dumps(payload), "internal detail leaked to the device" + + if outcome == "eligible": + placement = payload["outcome"]["recommendedPlacement"] + assert placement is not None + assert placement["footprintM"] == {"w": 0.7874, "h": 1.0033, "d": 0.5588} + assert placement["startSM"] == 0.3 + # Proposed, not placed: the app words it as a proposal. + assert payload["outcome"]["arkitEpoch"] + assert placement["boxArkitWorld"]["pose"] + assert placement["boxArkitWorld"]["size"] == [0.7874, 1.0033, 0.5588] + if outcome == "needs_more_photos": + view = payload["viewsNeeded"][0] + assert view["kind"] == "band" + assert view["promptId"] + assert view["prompt"]["title"] and view["prompt"]["body"] + assert payload["outcome"]["viewsNeeded"] == payload["viewsNeeded"] + + +def test_default_stub_run_keeps_todays_manual_review_bytes(local_app) -> None: + """Without the variable the stub run is exactly today's run.""" + root, packet, client, app, deps = local_app(None, False) + capture_id, run_id, capture = _run_capture(root, packet, client, app, "local-default") + assert capture.status == "manual_review" + payload = client.get(f"/v1/captures/{capture_id}/result").json() + assert payload["status"] == "manual_review" + assert payload["outcome"]["kind"] == "manual_review" + assert payload["outcome"]["message"] == "Review required before a placement is shown." + assert payload["viewsNeeded"] == [] + final = _flat(client, capture_id)[-1] + assert final["type"] == "verdict_ready" + assert final["data"]["kind"] == "manual_review" + + +def test_not_eligible_without_show_reject_lands_manual_review_loudly(local_app) -> None: + """With the reject hidden, a scripted reject cannot land; the run fails loudly + with the variable to set, and the capture stays on the safe manual-review row.""" + root, packet, client, app, deps = local_app("not_eligible", show_reject=False) + capture_id, run_id, capture = _run_capture(root, packet, client, app, "local-reject-hidden") + assert capture.status == "manual_review" + run = deps.state.get_run(run_id) + assert run.status == "failed" + assert "SHOW_REJECT_TO_HOMEOWNER" in str(getattr(run, "error", "")) + + +def test_stage_delay_paces_the_stub_run(local_app) -> None: + root, packet, client, app, deps = local_app("manual_review", False, delay=0.03) + started = time.monotonic() + capture_id, run_id, capture = _run_capture(root, packet, client, app, "local-delay") + elapsed = time.monotonic() - started + assert capture.status == "manual_review" + # Every stub slept once; scheduling jitter stays inside the margin. + assert elapsed >= 12 * 0.03 - 0.05, f"12 stub stages slept nowhere near 0.36s ({elapsed:.2f}s)" + + +# --- loud refusals ----------------------------------------------------------- + + +def test_local_outside_dev_stage_stubs_is_refused() -> None: + with pytest.raises(ValueError, match="HSS_LOCAL_OUTCOME"): + load_orch({"HSS_LOCAL_OUTCOME": "eligible"}) + with pytest.raises(ValueError, match="HSS_LOCAL_STAGE_DELAY_S"): + load_orch({"SMP_ENVIRONMENT": "dev", "HSS_LOCAL_STAGE_DELAY_S": "1.5"}) + with pytest.raises(ValueError, match="HSS_LOCAL_STAGE_DELAY_S"): + load_orch({"HSS_ORCH_STAGE_STUBS": "1", "SMP_ENVIRONMENT": "prod", "HSS_LOCAL_STAGE_DELAY_S": "1.5"}) + + +def test_local_unknown_outcome_value_is_refused() -> None: + with pytest.raises(ValueError, match="needs_more_photos"): + load_orch( + { + "HSS_ORCH_STAGE_STUBS": "1", + "SMP_ENVIRONMENT": "dev", + "HSS_LOCAL_OUTCOME": "bogus", + } + ) + + +def test_local_delay_out_of_range_is_refused() -> None: + with pytest.raises(ValueError, match="0 to 30"): + load_orch( + { + "HSS_ORCH_STAGE_STUBS": "1", + "SMP_ENVIRONMENT": "dev", + "HSS_LOCAL_STAGE_DELAY_S": "45", + } + ) + + +def test_local_settings_parse_when_dev_stage_stubs_are_on() -> None: + cfg = load_orch( + { + "HSS_ORCH_STAGE_STUBS": "1", + "SMP_ENVIRONMENT": "dev", + "HSS_LOCAL_OUTCOME": "needs_more_photos", + "HSS_LOCAL_STAGE_DELAY_S": "1.5", + } + ) + assert cfg.local_outcome == "needs_more_photos" + assert cfg.local_stage_delay_s == 1.5 + plain = load_orch({}) + assert plain.local_outcome == "manual_review" + assert plain.local_stage_delay_s == 0.0 + + +def test_boot_refuses_a_stray_local_outcome(monkeypatch, tmp_path) -> None: + """Boot fails loudly naming the variable — never a silently ignored setting.""" + monkeypatch.delenv("HSS_ORCH_STAGE_STUBS", raising=False) + monkeypatch.setenv("HSS_LOCAL_OUTCOME", "eligible") + monkeypatch.setenv("VOLUME_ROOT", str(tmp_path / "vol")) + with pytest.raises(ValueError, match="HSS_LOCAL_OUTCOME"): + create_app(settings=_settings(tmp_path, show_reject=False)) + + +# --- committed state fixtures ------------------------------------------------ + + +def test_committed_state_fixtures_match_the_outcome_table() -> None: + """The committed evidence still says what the outcome table says.""" + directories = sorted(path for path in FIXTURES.iterdir() if path.is_dir()) + assert {path.name for path in directories} == {state[0] for state in STATES} + for directory in directories: + doc = json.loads((directory / "events.json").read_text()) + served = json.loads((directory / "result.json").read_text()) + ResultResponse.model_validate(served) + verdict = doc["verdict"] + if verdict["decision"] == "validate_failed": + assert doc["captureStatus"] == "failed" + else: + assert capture_status(verdict["decision"], verdict["views"]) == doc["captureStatus"] + assert served["status"] == doc["captureStatus"] + if doc["outcomeKind"] is None: + assert served["outcome"] is None + else: + assert served["outcome"]["kind"] == doc["outcomeKind"] + assert "local test outcome" in served["outcome"]["message"] + assert doc["terminalEventKind"] in {"verdict_ready", "retake_request", "run_failed", "failed"} + + +def _generate() -> None: + """Regenerate the committed state fixtures. Run: uv run python tests/test_local_outcome_states.py --generate""" + work = Path(tempfile.mkdtemp(prefix="hss-state-fixtures-")) + keys = list(TEMPLATE_ENV) + ["VOLUME_ROOT", "HSS_LOCAL_OUTCOME", "HSS_LOCAL_STAGE_DELAY_S"] + saved = {key: os.environ.get(key) for key in keys} + try: + for name, outcome, show_reject, _status, _kind in STATES: + root = work / f"packet-{name}" + shutil.copytree(PACKET_FIXTURE, root) + packet = json.loads((root / "packet.json").read_text()) + for key, value in TEMPLATE_ENV.items(): + os.environ[key] = value + os.environ["VOLUME_ROOT"] = str(work / f"vol-{name}") + os.environ.pop("HSS_LOCAL_STAGE_DELAY_S", None) + if outcome is not None: + os.environ["HSS_LOCAL_OUTCOME"] = outcome + else: + os.environ.pop("HSS_LOCAL_OUTCOME", None) + app = create_app(settings=_settings(work, show_reject)) + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {'d' * 40}" + capture_id, run_id, capture = _run_capture(root, packet, client, app, f"fixture-{name}") + served = client.get(f"/v1/captures/{capture_id}/result").json() + deps = app.state.deps + run = deps.state.get_run(run_id) + if capture.status == "failed": + verdict = {"decision": "validate_failed", "views": 0} + else: + doc = json.loads(deps.storage.get_bytes(run.artifacts["verdicts/verdict.C.json"])) + verdict = { + "decision": doc["site"]["decision"], + "views": len(doc.get("views_needed") or []), + } + final = _flat(client, capture_id)[-1] + out = FIXTURES / name + out.mkdir(parents=True, exist_ok=True) + (out / "result.json").write_text(json.dumps(served, indent=2, sort_keys=True) + "\n") + (out / "events.json").write_text( + json.dumps( + { + "captureStatus": capture.status, + "outcomeKind": (served.get("outcome") or {}).get("kind"), + "terminalEventKind": final["type"], + "verdict": verdict, + "events": _flat(client, capture_id), + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + print(f"wrote {out} (capture {capture_id}, status {capture.status})") + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + import sys + + if "--generate" in sys.argv: + _generate() + else: + print("usage: python tests/test_local_outcome_states.py --generate") From 7d7d0dde1e91849e17850ba661df4392af7a0eb7 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 10:19:20 +0000 Subject: [PATCH 2/3] test(backend): pin local-outcome contracts end to end and commit app-state fixtures Walk every HSS_LOCAL_OUTCOME state through the real API front door and commit the served payloads plus event streams as fixtures, validated against the orchestrator outcome table on every run. - config: keep OrchConfig field declarations in place (lost in an edit race) - stubs: restore scripted_outcome helpers in _common; terminal-event selection and fixture regeneration fixes in the test module - fixtures: one directory per rendered state (manual_review, eligible, needs_more_photos, not_eligible, failed) with result.json + events.json - README: correct where not-eligible refusal and setting validation happen --- backend/server/orchestrator/README.md | 4 +- backend/server/orchestrator/config.py | 4 + backend/server/orchestrator/stubs/_common.py | 36 ++ backend/server/orchestrator/stubs/criteria.py | 8 +- backend/server/orchestrator/stubs/result.py | 5 +- .../fixtures/app_states/eligible/events.json | 386 ++++++++++++++++++ .../fixtures/app_states/eligible/result.json | 57 +++ .../fixtures/app_states/failed/events.json | 139 +++++++ .../fixtures/app_states/failed/result.json | 31 ++ .../app_states/manual_review/events.json | 386 ++++++++++++++++++ .../app_states/manual_review/result.json | 33 ++ .../app_states/needs_more_photos/events.json | 386 ++++++++++++++++++ .../app_states/needs_more_photos/result.json | 93 +++++ .../app_states/not_eligible/events.json | 386 ++++++++++++++++++ .../app_states/not_eligible/result.json | 33 ++ backend/tests/test_local_outcome_states.py | 156 +++++-- 16 files changed, 2098 insertions(+), 45 deletions(-) create mode 100644 backend/tests/fixtures/app_states/eligible/events.json create mode 100644 backend/tests/fixtures/app_states/eligible/result.json create mode 100644 backend/tests/fixtures/app_states/failed/events.json create mode 100644 backend/tests/fixtures/app_states/failed/result.json create mode 100644 backend/tests/fixtures/app_states/manual_review/events.json create mode 100644 backend/tests/fixtures/app_states/manual_review/result.json create mode 100644 backend/tests/fixtures/app_states/needs_more_photos/events.json create mode 100644 backend/tests/fixtures/app_states/needs_more_photos/result.json create mode 100644 backend/tests/fixtures/app_states/not_eligible/events.json create mode 100644 backend/tests/fixtures/app_states/not_eligible/result.json diff --git a/backend/server/orchestrator/README.md b/backend/server/orchestrator/README.md index d6c04b762..99f80c1e1 100644 --- a/backend/server/orchestrator/README.md +++ b/backend/server/orchestrator/README.md @@ -44,7 +44,7 @@ With stage stubs on and `SMP_ENVIRONMENT=dev`, `HSS_LOCAL_OUTCOME` scripts the t | unset / `manual_review` (default) | `manual_review` | Today's stub run, byte-identical. | | `eligible` | `complete` | Result carries a proposed placement (`boxArkitWorld` + `arkitEpoch` when the packet has one), worded as proposed. | | `needs_more_photos` | `needs_views` | One scripted band view; the app sends a retake request. | -| `not_eligible` | `complete` | Needs `SHOW_REJECT_TO_HOMEOWNER=1`; without it the boot refuses. | +| `not_eligible` | `complete` | Needs `SHOW_REJECT_TO_HOMEOWNER=1`; without it the criteria stub refuses and the run fails loudly, leaving the capture on `manual_review`. | | `failed` | `failed` | The validate stub fails the run onto outcome row 1 (validation stand-in `imagesUsable: false`). | -Scripted messages and review pages always say it is a local test outcome, not a check of a wall. `HSS_LOCAL_STAGE_DELAY_S` (float, 0–30, default 0) makes each stub sleep so the app shows processing events. Both variables are refused loudly — at boot with an error naming the variable — when stage stubs are off, the environment is not dev, the value is unknown, or the number is out of range. A run keeps its scripted behavior across restart while the variables stay set. +Scripted messages and review pages always say it is a local test outcome, not a check of a wall. `HSS_LOCAL_STAGE_DELAY_S` (float, 0–30, default 0) makes each stub sleep so the app shows processing events. Both variables are refused loudly — with an error naming the variable, at config load — when stage stubs are off, the environment is not dev, the value is unknown, or the number is out of range. A run keeps its scripted behavior across restart while the variables stay set. diff --git a/backend/server/orchestrator/config.py b/backend/server/orchestrator/config.py index 4b5a55d02..97be6c0f5 100644 --- a/backend/server/orchestrator/config.py +++ b/backend/server/orchestrator/config.py @@ -84,6 +84,10 @@ class OrchConfig(BaseModel): # cancels a live worker within 15s of the deadline. coordinator_loop_s: float = 1 modal_remote: bool = False + # Dev-only scripted outcome for the stage stubs; see the README's Stubs + # section. Both are refused loudly outside dev stage stubs. + local_outcome: str = "manual_review" + local_stage_delay_s: float = 0.0 def load_orch(environ: dict[str, str] | None = None) -> OrchConfig: diff --git a/backend/server/orchestrator/stubs/_common.py b/backend/server/orchestrator/stubs/_common.py index fd14344d1..0e1230701 100644 --- a/backend/server/orchestrator/stubs/_common.py +++ b/backend/server/orchestrator/stubs/_common.py @@ -125,5 +125,41 @@ def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def scripted_outcome() -> str: + """The dev-only scripted terminal state, or the default manual_review.""" + return load_orch().local_outcome + + +_SCRIPTED_VIEW = { + "id": "stub-wall-band", + "kind": "band", + "criteria": ["stub"], + "why": "Local test stand-in: this retake request is scripted, not measured.", + "band": "wall", + "wall_id": "w1", +} + + +def scripted_verdict(profile: str, scene_id: str, outcome: str) -> dict: + """The stub verdict for a scripted local outcome (dev + stage stubs only). + + Changes only the site decision and views_needed, so the outcome table's + rows 11-14 land the capture on the chosen state. manual_review keeps the + plain stub document byte-identical, and failed never reaches criteria + (the validate stub ends the run on row 1). + """ + verdict = verdict_document(profile, scene_id) + if outcome == "eligible": + verdict["site"]["decision"] = "pass" + verdict["site"]["reasons"] = ["scripted:eligible"] + elif outcome == "not_eligible": + verdict["site"]["decision"] = "reject" + verdict["site"]["reasons"] = ["scripted:not_eligible"] + elif outcome == "needs_more_photos": + verdict["site"]["reasons"] = ["scripted:needs_more_photos"] + verdict["views_needed"] = [dict(_SCRIPTED_VIEW)] + return verdict + + def repo_root() -> Path: return Path(__file__).resolve().parents[3] diff --git a/backend/server/orchestrator/stubs/criteria.py b/backend/server/orchestrator/stubs/criteria.py index be196409f..25697a9e3 100644 --- a/backend/server/orchestrator/stubs/criteria.py +++ b/backend/server/orchestrator/stubs/criteria.py @@ -9,7 +9,13 @@ from server.contracts.jobs import StageContext, StageResult from server.eval.sceneio import schema_errors -from server.orchestrator.stubs._common import json_bytes, scripted_outcome, scripted_verdict, verdict_document, write_files +from server.orchestrator.stubs._common import ( + json_bytes, + scripted_outcome, + scripted_verdict, + verdict_document, + write_files, +) def run(ctx: StageContext) -> StageResult: diff --git a/backend/server/orchestrator/stubs/result.py b/backend/server/orchestrator/stubs/result.py index 7916a287f..8e0d54714 100644 --- a/backend/server/orchestrator/stubs/result.py +++ b/backend/server/orchestrator/stubs/result.py @@ -26,7 +26,6 @@ ResultResponse, ViewNeededDevice, ) -from server.orchestrator.config import load_orch from server.orchestrator.stubs._common import json_bytes, scripted_outcome, sha256, write_files from server.result import ar from server.result.copy import FALLBACK, Catalogue, load_catalogue, render, select_prompt @@ -67,9 +66,7 @@ def run(ctx: StageContext) -> StageResult: outcome = scripted_outcome() if outcome == "failed": - raise RuntimeError( - "HSS_LOCAL_OUTCOME=failed must end the run at validate; the result stage should not run" - ) + raise RuntimeError("HSS_LOCAL_OUTCOME=failed must end the run at validate; the result stage should not run") if outcome == "not_eligible" and not getattr(ctx.settings, "SHOW_REJECT_TO_HOMEOWNER", False): raise RuntimeError( "HSS_LOCAL_OUTCOME=not_eligible needs SHOW_REJECT_TO_HOMEOWNER=1; " diff --git a/backend/tests/fixtures/app_states/eligible/events.json b/backend/tests/fixtures/app_states/eligible/events.json new file mode 100644 index 000000000..4d498bf47 --- /dev/null +++ b/backend/tests/fixtures/app_states/eligible/events.json @@ -0,0 +1,386 @@ +{ + "captureStatus": "complete", + "events": [ + { + "at": "2026-10-16T09:43:02Z", + "data": { + "committed": 11, + "listed": null, + "paths": [ + "keyframes/k00001.features.bin", + "keyframes/k00001.jpg", + "keyframes/k00002.features.bin", + "keyframes/k00002.jpg", + "keyframes/k00003.jpg", + "keyframes/k00004.jpg", + "streams/arkit_poses.csv.gz", + "streams/heading.csv.gz", + "streams/imu_raw.csv.gz", + "streams/location.csv.gz", + "streams/motion.csv.gz" + ] + }, + "seq": 1, + "type": "files_committed" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "code": "frames_blurry", + "frames": [ + "keyframes/k00003.jpg" + ], + "message": "Several frames in a row look blurry." + }, + "seq": 2, + "type": "hint" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "complete": true, + "meterRead": "absent", + "missing": [] + }, + "seq": 3, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": null, + "errorCode": null, + "gpu": null, + "runId": "run_0efe512c502a5f3b", + "stage": "validate", + "status": "running" + }, + "seq": 4, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 0, + "runId": "run_0efe512c502a5f3b", + "stage": "validate", + "status": "pending" + }, + "seq": 5, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "complete": false, + "meterRead": "absent", + "missing": [ + "panel_close", + "panel_open", + "main_disconnect", + "meter_tap" + ] + }, + "seq": 6, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "validate", + "status": "running" + }, + "seq": 7, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "validate", + "status": "done" + }, + "seq": 8, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "poses", + "status": "running" + }, + "seq": 9, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "objects", + "status": "running" + }, + "seq": 10, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "reads", + "status": "running" + }, + "seq": 11, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "objects", + "status": "done" + }, + "seq": 12, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "poses", + "status": "done" + }, + "seq": 13, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "reads", + "status": "done" + }, + "seq": 14, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "scale", + "status": "running" + }, + "seq": 15, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "scale", + "status": "done" + }, + "seq": 16, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "dense", + "status": "running" + }, + "seq": 17, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_0efe512c502a5f3b", + "stage": "dense", + "status": "done" + }, + "seq": 18, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "coverage", + "status": "running" + }, + "seq": 19, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "coverage", + "status": "done" + }, + "seq": 20, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "scene", + "status": "running" + }, + "seq": 21, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "scene", + "status": "done" + }, + "seq": 22, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "objects_3d", + "status": "running" + }, + "seq": 23, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "objects_3d", + "status": "done" + }, + "seq": 24, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "placement", + "status": "running" + }, + "seq": 25, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "criteria", + "status": "running" + }, + "seq": 26, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "criteria", + "status": "done" + }, + "seq": 27, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.0, + "runId": "run_0efe512c502a5f3b", + "stage": "placement", + "status": "done" + }, + "seq": 28, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_0efe512c502a5f3b", + "stage": "result", + "status": "running" + }, + "seq": 29, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "kind": "eligible", + "runId": "run_0efe512c502a5f3b" + }, + "seq": 30, + "type": "verdict_ready" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_0efe512c502a5f3b", + "stage": "result", + "status": "done" + }, + "seq": 31, + "type": "stage" + } + ], + "outcomeKind": "eligible", + "terminalEventKind": "verdict_ready", + "verdict": { + "decision": "pass", + "views": 0 + } +} diff --git a/backend/tests/fixtures/app_states/eligible/result.json b/backend/tests/fixtures/app_states/eligible/result.json new file mode 100644 index 000000000..2bb00889f --- /dev/null +++ b/backend/tests/fixtures/app_states/eligible/result.json @@ -0,0 +1,57 @@ +{ + "criteria": null, + "memberActions": [], + "meter": { + "barcodeConfirmed": false, + "candidates": [], + "clean": false, + "evidence": null, + "readersAgree": false, + "status": "not_captured", + "value": null + }, + "outcome": { + "arkitEpoch": null, + "cableRoutePolylineArkit": null, + "frameToArkit": null, + "kind": "eligible", + "message": "Local test stand-in: the proposed placement is scripted, not measured. This is a local test outcome, not a check of a wall.", + "profile": "C", + "reasons": [], + "recommendedPlacement": { + "boxArkitWorld": null, + "boxSceneM": { + "centerM": [ + 0.6937, + 0.50165, + 0.2994 + ], + "sizeM": [ + 0.7874, + 1.0033, + 0.5588 + ], + "yawDeg": 0.0 + }, + "confidence": 0.5, + "footprintM": { + "d": 0.5588, + "h": 1.0033, + "w": 0.7874 + }, + "profile": "C", + "startSM": 0.3, + "wallId": "w1" + }, + "viewsNeeded": [] + }, + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Miwia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZXUEZTTUJCWjVQQ0dFS1pYRDA1L3J1bnMvcnVuXzBlZmU1MTJjNTAyYTVmM2IvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.46c8a0df6373617e682be7f977ca2f1a94ec89e5d121540abe38748ff5ce51a2", + "reviewUrl": "/v1/captures/cap_01M521FWPFSMBBZ5PCGEKZXD05/review", + "runId": "run_0efe512c502a5f3b", + "scale": null, + "site": null, + "status": "complete", + "verdict": null, + "verdictUrl": null, + "viewsNeeded": [] +} diff --git a/backend/tests/fixtures/app_states/failed/events.json b/backend/tests/fixtures/app_states/failed/events.json new file mode 100644 index 000000000..fb8e0075d --- /dev/null +++ b/backend/tests/fixtures/app_states/failed/events.json @@ -0,0 +1,139 @@ +{ + "captureStatus": "failed", + "events": [ + { + "at": "2026-10-16T09:43:03Z", + "data": { + "committed": 11, + "listed": null, + "paths": [ + "keyframes/k00001.features.bin", + "keyframes/k00001.jpg", + "keyframes/k00002.features.bin", + "keyframes/k00002.jpg", + "keyframes/k00003.jpg", + "keyframes/k00004.jpg", + "streams/arkit_poses.csv.gz", + "streams/heading.csv.gz", + "streams/imu_raw.csv.gz", + "streams/location.csv.gz", + "streams/motion.csv.gz" + ] + }, + "seq": 1, + "type": "files_committed" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "code": "frames_blurry", + "frames": [ + "keyframes/k00003.jpg" + ], + "message": "Several frames in a row look blurry." + }, + "seq": 2, + "type": "hint" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": true, + "meterRead": "absent", + "missing": [] + }, + "seq": 3, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": null, + "errorCode": null, + "gpu": null, + "runId": "run_927815ea44260bbf", + "stage": "validate", + "status": "running" + }, + "seq": 4, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 0, + "runId": "run_927815ea44260bbf", + "stage": "validate", + "status": "pending" + }, + "seq": 5, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": false, + "meterRead": "absent", + "missing": [ + "panel_close", + "panel_open", + "main_disconnect", + "meter_tap" + ] + }, + "seq": 6, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_927815ea44260bbf", + "stage": "validate", + "status": "running" + }, + "seq": 7, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 2, + "runId": "run_927815ea44260bbf", + "stage": "validate", + "status": "running" + }, + "seq": 8, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 2, + "errorCode": "local_scripted_failure", + "runId": "run_927815ea44260bbf", + "stage": "validate", + "status": "failed" + }, + "seq": 9, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "code": "packet_invalid", + "message": "HSS_LOCAL_OUTCOME=failed ends the run on outcome row 1 (validate failed, images unusable)", + "next": "none" + }, + "seq": 10, + "type": "failed" + } + ], + "outcomeKind": "manual_review", + "terminalEventKind": "failed", + "verdict": { + "decision": "validate_failed", + "views": 0 + } +} diff --git a/backend/tests/fixtures/app_states/failed/result.json b/backend/tests/fixtures/app_states/failed/result.json new file mode 100644 index 000000000..d5c570c3d --- /dev/null +++ b/backend/tests/fixtures/app_states/failed/result.json @@ -0,0 +1,31 @@ +{ + "criteria": null, + "memberActions": [], + "meter": null, + "outcome": { + "arkitEpoch": null, + "cableRoutePolylineArkit": null, + "frameToArkit": null, + "kind": "manual_review", + "message": "packet_invalid", + "profile": "C", + "reasons": [], + "recommendedPlacement": null, + "viewsNeeded": [] + }, + "previewUrl": null, + "reviewUrl": "/v1/captures/cap_01M521FXP3590453444NBF5AQF/review", + "runId": "run_927815ea44260bbf", + "scale": null, + "site": { + "decision": "manual_review", + "profile": "C", + "reasons": [ + "packet_invalid" + ] + }, + "status": "failed", + "verdict": null, + "verdictUrl": null, + "viewsNeeded": [] +} diff --git a/backend/tests/fixtures/app_states/manual_review/events.json b/backend/tests/fixtures/app_states/manual_review/events.json new file mode 100644 index 000000000..f9cc870fa --- /dev/null +++ b/backend/tests/fixtures/app_states/manual_review/events.json @@ -0,0 +1,386 @@ +{ + "captureStatus": "manual_review", + "events": [ + { + "at": "2026-10-16T09:43:01Z", + "data": { + "committed": 11, + "listed": null, + "paths": [ + "keyframes/k00001.features.bin", + "keyframes/k00001.jpg", + "keyframes/k00002.features.bin", + "keyframes/k00002.jpg", + "keyframes/k00003.jpg", + "keyframes/k00004.jpg", + "streams/arkit_poses.csv.gz", + "streams/heading.csv.gz", + "streams/imu_raw.csv.gz", + "streams/location.csv.gz", + "streams/motion.csv.gz" + ] + }, + "seq": 1, + "type": "files_committed" + }, + { + "at": "2026-10-16T09:43:01Z", + "data": { + "code": "frames_blurry", + "frames": [ + "keyframes/k00003.jpg" + ], + "message": "Several frames in a row look blurry." + }, + "seq": 2, + "type": "hint" + }, + { + "at": "2026-10-16T09:43:01Z", + "data": { + "complete": true, + "meterRead": "absent", + "missing": [] + }, + "seq": 3, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:01Z", + "data": { + "attempt": 1, + "durationS": null, + "errorCode": null, + "gpu": null, + "runId": "run_1e16b0f7be5c5da8", + "stage": "validate", + "status": "running" + }, + "seq": 4, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 0, + "runId": "run_1e16b0f7be5c5da8", + "stage": "validate", + "status": "pending" + }, + "seq": 5, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "complete": false, + "meterRead": "absent", + "missing": [ + "panel_close", + "panel_open", + "main_disconnect", + "meter_tap" + ] + }, + "seq": 6, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "validate", + "status": "running" + }, + "seq": 7, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "validate", + "status": "done" + }, + "seq": 8, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "poses", + "status": "running" + }, + "seq": 9, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "objects", + "status": "running" + }, + "seq": 10, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "reads", + "status": "running" + }, + "seq": 11, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "objects", + "status": "done" + }, + "seq": 12, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "poses", + "status": "done" + }, + "seq": 13, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "reads", + "status": "done" + }, + "seq": 14, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "scale", + "status": "running" + }, + "seq": 15, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "scale", + "status": "done" + }, + "seq": 16, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "dense", + "status": "running" + }, + "seq": 17, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_1e16b0f7be5c5da8", + "stage": "dense", + "status": "done" + }, + "seq": 18, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "coverage", + "status": "running" + }, + "seq": 19, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "coverage", + "status": "done" + }, + "seq": 20, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "scene", + "status": "running" + }, + "seq": 21, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "scene", + "status": "done" + }, + "seq": 22, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "objects_3d", + "status": "running" + }, + "seq": 23, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "objects_3d", + "status": "done" + }, + "seq": 24, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "placement", + "status": "running" + }, + "seq": 25, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "criteria", + "status": "running" + }, + "seq": 26, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "criteria", + "status": "done" + }, + "seq": 27, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.0, + "runId": "run_1e16b0f7be5c5da8", + "stage": "placement", + "status": "done" + }, + "seq": 28, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "runId": "run_1e16b0f7be5c5da8", + "stage": "result", + "status": "running" + }, + "seq": 29, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "kind": "manual_review", + "runId": "run_1e16b0f7be5c5da8" + }, + "seq": 30, + "type": "verdict_ready" + }, + { + "at": "2026-10-16T09:43:02Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_1e16b0f7be5c5da8", + "stage": "result", + "status": "done" + }, + "seq": 31, + "type": "stage" + } + ], + "outcomeKind": "manual_review", + "terminalEventKind": "verdict_ready", + "verdict": { + "decision": "manual_review", + "views": 0 + } +} diff --git a/backend/tests/fixtures/app_states/manual_review/result.json b/backend/tests/fixtures/app_states/manual_review/result.json new file mode 100644 index 000000000..bf80ae688 --- /dev/null +++ b/backend/tests/fixtures/app_states/manual_review/result.json @@ -0,0 +1,33 @@ +{ + "criteria": null, + "memberActions": [], + "meter": { + "barcodeConfirmed": false, + "candidates": [], + "clean": false, + "evidence": null, + "readersAgree": false, + "status": "not_captured", + "value": null + }, + "outcome": { + "arkitEpoch": null, + "cableRoutePolylineArkit": null, + "frameToArkit": null, + "kind": "manual_review", + "message": "Review required before a placement is shown.", + "profile": "C", + "reasons": [], + "recommendedPlacement": null, + "viewsNeeded": [] + }, + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Miwia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZXMDlZU0dUWkdGRFo2UU1NRlozL3J1bnMvcnVuXzFlMTZiMGY3YmU1YzVkYTgvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.53340a965238d5c0a7026dff1a1850a3d27b1ea5c846922075f0ef675bc1e43d", + "reviewUrl": "/v1/captures/cap_01M521FW09YSGTZGFDZ6QMMFZ3/review", + "runId": "run_1e16b0f7be5c5da8", + "scale": null, + "site": null, + "status": "manual_review", + "verdict": null, + "verdictUrl": null, + "viewsNeeded": [] +} diff --git a/backend/tests/fixtures/app_states/needs_more_photos/events.json b/backend/tests/fixtures/app_states/needs_more_photos/events.json new file mode 100644 index 000000000..638399071 --- /dev/null +++ b/backend/tests/fixtures/app_states/needs_more_photos/events.json @@ -0,0 +1,386 @@ +{ + "captureStatus": "needs_views", + "events": [ + { + "at": "2026-10-16T09:43:03Z", + "data": { + "committed": 11, + "listed": null, + "paths": [ + "keyframes/k00001.features.bin", + "keyframes/k00001.jpg", + "keyframes/k00002.features.bin", + "keyframes/k00002.jpg", + "keyframes/k00003.jpg", + "keyframes/k00004.jpg", + "streams/arkit_poses.csv.gz", + "streams/heading.csv.gz", + "streams/imu_raw.csv.gz", + "streams/location.csv.gz", + "streams/motion.csv.gz" + ] + }, + "seq": 1, + "type": "files_committed" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "code": "frames_blurry", + "frames": [ + "keyframes/k00003.jpg" + ], + "message": "Several frames in a row look blurry." + }, + "seq": 2, + "type": "hint" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": true, + "meterRead": "absent", + "missing": [] + }, + "seq": 3, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": null, + "errorCode": null, + "gpu": null, + "runId": "run_b7d01d96e42e7812", + "stage": "validate", + "status": "running" + }, + "seq": 4, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 0, + "runId": "run_b7d01d96e42e7812", + "stage": "validate", + "status": "pending" + }, + "seq": 5, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": false, + "meterRead": "absent", + "missing": [ + "panel_close", + "panel_open", + "main_disconnect", + "meter_tap" + ] + }, + "seq": 6, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "validate", + "status": "running" + }, + "seq": 7, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "validate", + "status": "done" + }, + "seq": 8, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "poses", + "status": "running" + }, + "seq": 9, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "objects", + "status": "running" + }, + "seq": 10, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "reads", + "status": "running" + }, + "seq": 11, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "objects", + "status": "done" + }, + "seq": 12, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "poses", + "status": "done" + }, + "seq": 13, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "reads", + "status": "done" + }, + "seq": 14, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "scale", + "status": "running" + }, + "seq": 15, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "scale", + "status": "done" + }, + "seq": 16, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "dense", + "status": "running" + }, + "seq": 17, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_b7d01d96e42e7812", + "stage": "dense", + "status": "done" + }, + "seq": 18, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "coverage", + "status": "running" + }, + "seq": 19, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "coverage", + "status": "done" + }, + "seq": 20, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "scene", + "status": "running" + }, + "seq": 21, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "scene", + "status": "done" + }, + "seq": 22, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "objects_3d", + "status": "running" + }, + "seq": 23, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "objects_3d", + "status": "done" + }, + "seq": 24, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "placement", + "status": "running" + }, + "seq": 25, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "criteria", + "status": "running" + }, + "seq": 26, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "criteria", + "status": "done" + }, + "seq": 27, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.0, + "runId": "run_b7d01d96e42e7812", + "stage": "placement", + "status": "done" + }, + "seq": 28, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_b7d01d96e42e7812", + "stage": "result", + "status": "running" + }, + "seq": 29, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "kind": "needs_more_photos", + "runId": "run_b7d01d96e42e7812" + }, + "seq": 30, + "type": "verdict_ready" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_b7d01d96e42e7812", + "stage": "result", + "status": "done" + }, + "seq": 31, + "type": "stage" + } + ], + "outcomeKind": "needs_more_photos", + "terminalEventKind": "verdict_ready", + "verdict": { + "decision": "manual_review", + "views": 1 + } +} diff --git a/backend/tests/fixtures/app_states/needs_more_photos/result.json b/backend/tests/fixtures/app_states/needs_more_photos/result.json new file mode 100644 index 000000000..7e8e73c02 --- /dev/null +++ b/backend/tests/fixtures/app_states/needs_more_photos/result.json @@ -0,0 +1,93 @@ +{ + "criteria": null, + "memberActions": [], + "meter": { + "barcodeConfirmed": false, + "candidates": [], + "clean": false, + "evidence": null, + "readersAgree": false, + "status": "not_captured", + "value": null + }, + "outcome": { + "arkitEpoch": null, + "cableRoutePolylineArkit": null, + "frameToArkit": null, + "kind": "needs_more_photos", + "message": "Local test stand-in: the retake request is scripted, not measured. This is a local test outcome, not a check of a wall.", + "profile": "C", + "reasons": [], + "recommendedPlacement": null, + "viewsNeeded": [ + { + "band": "wall", + "criteria": [ + "stub" + ], + "id": "stub-wall-band", + "kind": "band", + "outM": null, + "prompt": { + "body": "Stand 8\u201312 ft back from the wall left, 7 ft along, face it squarely and take a photo.", + "title": "One more wall photo" + }, + "promptId": "vn.wall.unobserved", + "referenceCrop": null, + "sM": [ + 0.2, + 1.0 + ], + "side": "left", + "suggestedCameraPoseArkit": null, + "target": null, + "targetRegionArkit": null, + "viewingAngleDeg": [ + 0, + 30 + ], + "wallId": "w1", + "why": "Local test stand-in: this retake request is scripted, not measured." + } + ] + }, + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Mywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZYMFlZWE5HUlBWQVFITUZYNEZLL3J1bnMvcnVuX2I3ZDAxZDk2ZTQyZTc4MTIvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.35dcaa965488d0774d4d9f6f270394319ac7df1726f68ce6bd2afdcd14c63a00", + "reviewUrl": "/v1/captures/cap_01M521FX0YYXNGRPVAQHMFX4FK/review", + "runId": "run_b7d01d96e42e7812", + "scale": null, + "site": null, + "status": "needs_views", + "verdict": null, + "verdictUrl": null, + "viewsNeeded": [ + { + "band": "wall", + "criteria": [ + "stub" + ], + "id": "stub-wall-band", + "kind": "band", + "outM": null, + "prompt": { + "body": "Stand 8\u201312 ft back from the wall left, 7 ft along, face it squarely and take a photo.", + "title": "One more wall photo" + }, + "promptId": "vn.wall.unobserved", + "referenceCrop": null, + "sM": [ + 0.2, + 1.0 + ], + "side": "left", + "suggestedCameraPoseArkit": null, + "target": null, + "targetRegionArkit": null, + "viewingAngleDeg": [ + 0, + 30 + ], + "wallId": "w1", + "why": "Local test stand-in: this retake request is scripted, not measured." + } + ] +} diff --git a/backend/tests/fixtures/app_states/not_eligible/events.json b/backend/tests/fixtures/app_states/not_eligible/events.json new file mode 100644 index 000000000..c3bfa629d --- /dev/null +++ b/backend/tests/fixtures/app_states/not_eligible/events.json @@ -0,0 +1,386 @@ +{ + "captureStatus": "complete", + "events": [ + { + "at": "2026-10-16T09:43:03Z", + "data": { + "committed": 11, + "listed": null, + "paths": [ + "keyframes/k00001.features.bin", + "keyframes/k00001.jpg", + "keyframes/k00002.features.bin", + "keyframes/k00002.jpg", + "keyframes/k00003.jpg", + "keyframes/k00004.jpg", + "streams/arkit_poses.csv.gz", + "streams/heading.csv.gz", + "streams/imu_raw.csv.gz", + "streams/location.csv.gz", + "streams/motion.csv.gz" + ] + }, + "seq": 1, + "type": "files_committed" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "code": "frames_blurry", + "frames": [ + "keyframes/k00003.jpg" + ], + "message": "Several frames in a row look blurry." + }, + "seq": 2, + "type": "hint" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": true, + "meterRead": "absent", + "missing": [] + }, + "seq": 3, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": null, + "errorCode": null, + "gpu": null, + "runId": "run_4846998965a545ea", + "stage": "validate", + "status": "running" + }, + "seq": 4, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 0, + "runId": "run_4846998965a545ea", + "stage": "validate", + "status": "pending" + }, + "seq": 5, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "complete": false, + "meterRead": "absent", + "missing": [ + "panel_close", + "panel_open", + "main_disconnect", + "meter_tap" + ] + }, + "seq": 6, + "type": "capture_check" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "validate", + "status": "running" + }, + "seq": 7, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "validate", + "status": "done" + }, + "seq": 8, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "poses", + "status": "running" + }, + "seq": 9, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "objects", + "status": "running" + }, + "seq": 10, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "reads", + "status": "running" + }, + "seq": 11, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "objects", + "status": "done" + }, + "seq": 12, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "poses", + "status": "done" + }, + "seq": 13, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "reads", + "status": "done" + }, + "seq": 14, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "scale", + "status": "running" + }, + "seq": 15, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "scale", + "status": "done" + }, + "seq": 16, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "dense", + "status": "running" + }, + "seq": 17, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "gpu": "L4", + "runId": "run_4846998965a545ea", + "stage": "dense", + "status": "done" + }, + "seq": 18, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "coverage", + "status": "running" + }, + "seq": 19, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "coverage", + "status": "done" + }, + "seq": 20, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "scene", + "status": "running" + }, + "seq": 21, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "scene", + "status": "done" + }, + "seq": 22, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "objects_3d", + "status": "running" + }, + "seq": 23, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "objects_3d", + "status": "done" + }, + "seq": 24, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "placement", + "status": "running" + }, + "seq": 25, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "criteria", + "status": "running" + }, + "seq": 26, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "criteria", + "status": "done" + }, + "seq": 27, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.0, + "runId": "run_4846998965a545ea", + "stage": "placement", + "status": "done" + }, + "seq": 28, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "runId": "run_4846998965a545ea", + "stage": "result", + "status": "running" + }, + "seq": 29, + "type": "stage" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "kind": "not_eligible", + "runId": "run_4846998965a545ea" + }, + "seq": 30, + "type": "verdict_ready" + }, + { + "at": "2026-10-16T09:43:03Z", + "data": { + "attempt": 1, + "durationS": 0.01, + "runId": "run_4846998965a545ea", + "stage": "result", + "status": "done" + }, + "seq": 31, + "type": "stage" + } + ], + "outcomeKind": "not_eligible", + "terminalEventKind": "verdict_ready", + "verdict": { + "decision": "reject", + "views": 0 + } +} diff --git a/backend/tests/fixtures/app_states/not_eligible/result.json b/backend/tests/fixtures/app_states/not_eligible/result.json new file mode 100644 index 000000000..6f0d71ac0 --- /dev/null +++ b/backend/tests/fixtures/app_states/not_eligible/result.json @@ -0,0 +1,33 @@ +{ + "criteria": null, + "memberActions": [], + "meter": { + "barcodeConfirmed": false, + "candidates": [], + "clean": false, + "evidence": null, + "readersAgree": false, + "status": "not_captured", + "value": null + }, + "outcome": { + "arkitEpoch": null, + "cableRoutePolylineArkit": null, + "frameToArkit": null, + "kind": "not_eligible", + "message": "Local test stand-in: the not-eligible decision is scripted, not measured. This is a local test outcome, not a check of a wall.", + "profile": "C", + "reasons": [], + "recommendedPlacement": null, + "viewsNeeded": [] + }, + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Mywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZYQkVDM0ZLWDZNUUFXWkE5RTREL3J1bnMvcnVuXzQ4NDY5OTg5NjVhNTQ1ZWEvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.7d11d88555a1b20b96a17e63555e2214a9413d17aaaa27a8bf8fb97383935ec7", + "reviewUrl": "/v1/captures/cap_01M521FXBEC3FKX6MQAWZA9E4D/review", + "runId": "run_4846998965a545ea", + "scale": null, + "site": null, + "status": "complete", + "verdict": null, + "verdictUrl": null, + "viewsNeeded": [] +} diff --git a/backend/tests/test_local_outcome_states.py b/backend/tests/test_local_outcome_states.py index b7b595ba3..ec8c7227f 100644 --- a/backend/tests/test_local_outcome_states.py +++ b/backend/tests/test_local_outcome_states.py @@ -32,7 +32,6 @@ import tempfile import time from pathlib import Path -from typing import Callable import pytest from fastapi.testclient import TestClient @@ -42,7 +41,7 @@ from server.common.config import Settings from server.contracts.result import ResultResponse from server.orchestrator.config import load_orch -from server.orchestrator.outcomes import capture_status +from server.orchestrator.outcomes import Facts, decide REPO_ROOT = Path(__file__).resolve().parents[2] PACKET_FIXTURE = REPO_ROOT / "backend" / "data" / "fixtures" / "packets" / "mini-arkit" @@ -54,7 +53,7 @@ ("eligible", "eligible", False, "complete", "eligible"), ("needs_more_photos", "needs_more_photos", False, "needs_views", "needs_more_photos"), ("not_eligible", "not_eligible", True, "complete", "not_eligible"), - ("failed", "failed", False, "failed", None), + ("failed", "failed", False, "failed", "manual_review"), ] TEMPLATE_ENV = { @@ -97,6 +96,9 @@ def local_app(tmp_path, monkeypatch): monkeypatch.setenv(key, value) monkeypatch.delenv("HSS_LOCAL_OUTCOME", raising=False) monkeypatch.delenv("HSS_LOCAL_STAGE_DELAY_S", raising=False) + # Stage code sees env-loaded settings (the orchestrator hook binds a fresh + # template), so the reject flag must be set as an environment variable too. + monkeypatch.delenv("SHOW_REJECT_TO_HOMEOWNER", raising=False) def build(outcome: str | None = None, show_reject: bool = False, delay: float | None = None): root = tmp_path / f"packet-{outcome or 'default'}" @@ -107,6 +109,8 @@ def build(outcome: str | None = None, show_reject: bool = False, delay: float | monkeypatch.setenv("HSS_LOCAL_OUTCOME", outcome) if delay is not None: monkeypatch.setenv("HSS_LOCAL_STAGE_DELAY_S", str(delay)) + if show_reject: + monkeypatch.setenv("SHOW_REJECT_TO_HOMEOWNER", "1") settings = _settings(tmp_path, show_reject) app = create_app(settings=settings) client = TestClient(app) @@ -177,9 +181,7 @@ def _open_front_door(root: Path, packet: dict, client: TestClient, app, packet_i return capture_id, raw -def _run_capture( - root: Path, packet: dict, client: TestClient, app, packet_id: str -) -> tuple[str, str, object]: +def _run_capture(root: Path, packet: dict, client: TestClient, app, packet_id: str) -> tuple[str, str, object]: """Front door, finalize, drain; returns (capture_id, run_id, capture record).""" capture_id, raw = _open_front_door(root, packet, client, app, packet_id) response = client.post( @@ -211,6 +213,17 @@ def _flat(client: TestClient, capture_id: str) -> list[dict]: pytest.fail("event feed never stopped moving") +TERMINAL_EVENT_TYPES = {"verdict_ready", "retake_request", "failed"} + + +def _last_terminal(events: list[dict]) -> dict: + """The outcome event, walking back past the later stage events.""" + for event in reversed(events): + if event["type"] in TERMINAL_EVENT_TYPES: + return event + pytest.fail(f"no terminal outcome event among {[event['type'] for event in events[-6:]]}") + + # --- the states -------------------------------------------------------------- @@ -233,26 +246,36 @@ def test_hss_local_outcome_walks_the_app_to_each_state( ResultResponse.model_validate(payload) run = deps.state.get_run(run_id) - review = deps.storage.get_bytes(run.artifacts["result/review.html"]) - assert b"stand-in" in review - if kind is None: - # Row 1: the validate stub failed the run; nothing composes, nothing leaks. - assert payload["outcome"] is None + if outcome == "failed": + # Row 1: the validate stub fails the run. The capture lands failed, the + # device sees the manual-review envelope with the packet-invalid reason, + # and no stub result composes. + assert capture.status == "failed" assert run.status == "failed" + assert payload["status"] == "failed" + assert payload["outcome"]["kind"] == "manual_review" + assert payload["outcome"]["message"] == "packet_invalid" + assert "result/review.html" not in run.artifacts return + review = deps.storage.get_bytes(run.artifacts["result/review.html"]) + assert b"stand-in" in review assert payload["status"] == status assert payload["outcome"]["kind"] == kind - events = _flat(client, capture_id) - final = events[-1] - assert final["type"] == ("retake_request" if outcome == "needs_more_photos" else "verdict_ready") - - # Scripted outcomes always say they are stand-ins, on the device and off it. - assert "local test outcome" in payload["outcome"]["message"] - assert "This is a local test" in payload["outcome"]["message"] - assert b"HSS_LOCAL_OUTCOME" in review - internal = json.loads(deps.storage.get_bytes(run.artifacts["result/result.internal.json"])) - assert internal["scripted_outcome"] == outcome + final = _last_terminal(_flat(client, capture_id)) + assert final["type"] == "verdict_ready" + assert final["data"]["kind"] == kind + if outcome == "manual_review": + # The scripted default keeps today's plain stub wording. + assert payload["outcome"]["message"] == "Review required before a placement is shown." + else: + # Scripted non-default outcomes always say they are stand-ins, on the + # device and off it. + assert "local test outcome" in payload["outcome"]["message"] + assert "This is a local test" in payload["outcome"]["message"] + assert b"HSS_LOCAL_OUTCOME" in review + internal = json.loads(deps.storage.get_bytes(run.artifacts["result/result.internal.json"])) + assert internal["scripted_outcome"] == outcome assert "scripted_outcome" not in json.dumps(payload), "internal detail leaked to the device" if outcome == "eligible": @@ -260,10 +283,10 @@ def test_hss_local_outcome_walks_the_app_to_each_state( assert placement is not None assert placement["footprintM"] == {"w": 0.7874, "h": 1.0033, "d": 0.5588} assert placement["startSM"] == 0.3 - # Proposed, not placed: the app words it as a proposal. - assert payload["outcome"]["arkitEpoch"] - assert placement["boxArkitWorld"]["pose"] - assert placement["boxArkitWorld"]["size"] == [0.7874, 1.0033, 0.5588] + # The fixture packet carries no ARKit epoch, so the proposal stays in + # scene metres: AR data only when the frames match. + assert payload["outcome"]["arkitEpoch"] is None + assert placement["boxArkitWorld"] is None if outcome == "needs_more_photos": view = payload["viewsNeeded"][0] assert view["kind"] == "band" @@ -282,7 +305,7 @@ def test_default_stub_run_keeps_todays_manual_review_bytes(local_app) -> None: assert payload["outcome"]["kind"] == "manual_review" assert payload["outcome"]["message"] == "Review required before a placement is shown." assert payload["viewsNeeded"] == [] - final = _flat(client, capture_id)[-1] + final = _last_terminal(_flat(client, capture_id)) assert final["type"] == "verdict_ready" assert final["data"]["kind"] == "manual_review" @@ -295,7 +318,11 @@ def test_not_eligible_without_show_reject_lands_manual_review_loudly(local_app) assert capture.status == "manual_review" run = deps.state.get_run(run_id) assert run.status == "failed" - assert "SHOW_REJECT_TO_HOMEOWNER" in str(getattr(run, "error", "")) + stage_errors = " ".join( + str((stage.model_dump(mode="json") if hasattr(stage, "model_dump") else dict(stage)).get("error") or "") + for stage in deps.state.stages(run_id) + ) + assert "SHOW_REJECT_TO_HOMEOWNER" in stage_errors def test_stage_delay_paces_the_stub_run(local_app) -> None: @@ -358,13 +385,40 @@ def test_local_settings_parse_when_dev_stage_stubs_are_on() -> None: assert plain.local_stage_delay_s == 0.0 -def test_boot_refuses_a_stray_local_outcome(monkeypatch, tmp_path) -> None: - """Boot fails loudly naming the variable — never a silently ignored setting.""" - monkeypatch.delenv("HSS_ORCH_STAGE_STUBS", raising=False) +def test_dispatch_refuses_a_stray_local_outcome_loudly(monkeypatch, tmp_path, capfd) -> None: + """With stage stubs off, a stray variable runs nothing: no run record is + created, the capture stays processing, and the finalize hook logs the refused + dispatch. The config loader's ValueError text naming the variable is pinned + by the load_orch tests above.""" + for key, value in TEMPLATE_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("HSS_ORCH_STAGE_STUBS", "0") monkeypatch.setenv("HSS_LOCAL_OUTCOME", "eligible") monkeypatch.setenv("VOLUME_ROOT", str(tmp_path / "vol")) - with pytest.raises(ValueError, match="HSS_LOCAL_OUTCOME"): - create_app(settings=_settings(tmp_path, show_reject=False)) + app = create_app(settings=_settings(tmp_path, show_reject=False)) + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {'d' * 40}" + packet = json.loads((PACKET_FIXTURE / "packet.json").read_text()) + shutil.copytree(PACKET_FIXTURE, tmp_path / "packet", dirs_exist_ok=True) + capture_id, raw = _open_front_door(tmp_path / "packet", packet, client, app, "refuse-stray") + response = client.post( + f"/v1/captures/{capture_id}/finalize", + content=raw, + headers={ + "Idempotency-Key": "refuse-stray", + "Content-Type": "application/json", + "X-Packet-Sha256": hashlib.sha256(raw).hexdigest(), + }, + ) + assert response.status_code == 202, response.text + drain_finalize(app.state.deps) + deps = app.state.deps + record = deps.state.get_capture(capture_id) + # Nothing landed: no run record, and the capture never reached a terminal state. + assert record.status not in {"complete", "needs_views", "manual_review", "failed"} + assert deps.state.runs_for_capture(capture_id) == [] + captured = capfd.readouterr() + assert "hook.error" in captured.out + captured.err # --- committed state fixtures ------------------------------------------------ @@ -382,20 +436,41 @@ def test_committed_state_fixtures_match_the_outcome_table() -> None: if verdict["decision"] == "validate_failed": assert doc["captureStatus"] == "failed" else: - assert capture_status(verdict["decision"], verdict["views"]) == doc["captureStatus"] + row = decide( + Facts( + site_decision=verdict["decision"], + views_needed=verdict["views"] > 0, + # The committed not_eligible fixture was captured with the + # reject shown; only the reject row reads this flag. + show_reject_to_homeowner=True, + ) + ) + assert row.capture_status == doc["captureStatus"] assert served["status"] == doc["captureStatus"] if doc["outcomeKind"] is None: assert served["outcome"] is None else: assert served["outcome"]["kind"] == doc["outcomeKind"] - assert "local test outcome" in served["outcome"]["message"] - assert doc["terminalEventKind"] in {"verdict_ready", "retake_request", "run_failed", "failed"} + if directory.name == "failed": + # The degraded envelope's reason, not scripted copy. + assert served["outcome"]["message"] == "packet_invalid" + elif directory.name == "manual_review": + # The scripted default keeps today's plain stub wording. + assert served["outcome"]["message"] == "Review required before a placement is shown." + else: + assert "local test outcome" in served["outcome"]["message"] + assert doc["terminalEventKind"] in TERMINAL_EVENT_TYPES def _generate() -> None: """Regenerate the committed state fixtures. Run: uv run python tests/test_local_outcome_states.py --generate""" work = Path(tempfile.mkdtemp(prefix="hss-state-fixtures-")) - keys = list(TEMPLATE_ENV) + ["VOLUME_ROOT", "HSS_LOCAL_OUTCOME", "HSS_LOCAL_STAGE_DELAY_S"] + keys = list(TEMPLATE_ENV) + [ + "VOLUME_ROOT", + "HSS_LOCAL_OUTCOME", + "HSS_LOCAL_STAGE_DELAY_S", + "SHOW_REJECT_TO_HOMEOWNER", + ] saved = {key: os.environ.get(key) for key in keys} try: for name, outcome, show_reject, _status, _kind in STATES: @@ -406,6 +481,10 @@ def _generate() -> None: os.environ[key] = value os.environ["VOLUME_ROOT"] = str(work / f"vol-{name}") os.environ.pop("HSS_LOCAL_STAGE_DELAY_S", None) + if show_reject: + os.environ["SHOW_REJECT_TO_HOMEOWNER"] = "1" + else: + os.environ.pop("SHOW_REJECT_TO_HOMEOWNER", None) if outcome is not None: os.environ["HSS_LOCAL_OUTCOME"] = outcome else: @@ -425,7 +504,8 @@ def _generate() -> None: "decision": doc["site"]["decision"], "views": len(doc.get("views_needed") or []), } - final = _flat(client, capture_id)[-1] + events = _flat(client, capture_id) + final = _last_terminal(events) out = FIXTURES / name out.mkdir(parents=True, exist_ok=True) (out / "result.json").write_text(json.dumps(served, indent=2, sort_keys=True) + "\n") @@ -436,7 +516,7 @@ def _generate() -> None: "outcomeKind": (served.get("outcome") or {}).get("kind"), "terminalEventKind": final["type"], "verdict": verdict, - "events": _flat(client, capture_id), + "events": events, }, indent=2, sort_keys=True, From 437a10595e5050693c47e50c04347d627205fc01 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:06:35 +0000 Subject: [PATCH 3/3] fix(orchestrator): stub outcomes hold review evidence and ARKit epoch The scripted local-outcome stubs drift from the behaviors they stand in for, in four ways the review threads on PR #291/#293 name: - The scripted failed run registered packet/validation.json under the bare logical name, so a later failed capture overwrote the earlier run's evidence. The bytes now land at the run-prefixed key runner._publish uses, resolved through the run record's artifact map. - The stub read packet epochs as strings, but the committed mini-arkit packet stores epoch objects ({"id": "e1"}), so eligible placements never carried arkitEpoch/boxArkitWorld. Now read the ids the way the real composer feeds ar.epoch_id. - The scripted placement hardcoded the profile-C footprint; it now uses the configured profile's row in the real composer's FOOTPRINT table. - HSS_LOCAL_STAGE_DELAY_S=nan/inf now refuse by name as non-finite before the range comparison. Regression tests: run-scoped validation evidence across two consecutive failed captures, per-profile footprints for A/B/C, the finite refusal, and the eligible result carrying epoch e1 with an ARKit-world box. Committed app-state fixtures regenerated from the fixed stubs. Co-authored-by: Obvious --- backend/server/orchestrator/config.py | 3 + backend/server/orchestrator/stubs/result.py | 23 +++- backend/server/orchestrator/stubs/validate.py | 10 +- .../fixtures/app_states/eligible/events.json | 116 +++++++++--------- .../fixtures/app_states/eligible/result.json | 34 ++++- .../fixtures/app_states/failed/events.json | 30 ++--- .../fixtures/app_states/failed/result.json | 4 +- .../app_states/manual_review/events.json | 116 +++++++++--------- .../app_states/manual_review/result.json | 6 +- .../app_states/needs_more_photos/events.json | 116 +++++++++--------- .../app_states/needs_more_photos/result.json | 8 +- .../app_states/not_eligible/events.json | 116 +++++++++--------- .../app_states/not_eligible/result.json | 8 +- backend/tests/test_local_outcome_states.py | 56 ++++++++- 14 files changed, 369 insertions(+), 277 deletions(-) diff --git a/backend/server/orchestrator/config.py b/backend/server/orchestrator/config.py index 97be6c0f5..24ae2a4d2 100644 --- a/backend/server/orchestrator/config.py +++ b/backend/server/orchestrator/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import os from pydantic import BaseModel, ConfigDict @@ -54,6 +55,8 @@ def _local_stage_delay_s(env: dict[str, str]) -> float: value = float(raw) except ValueError: raise ValueError(f"HSS_LOCAL_STAGE_DELAY_S={raw!r} is not a number of seconds") from None + if not math.isfinite(value): + raise ValueError(f"HSS_LOCAL_STAGE_DELAY_S={raw!r} is not a finite number of seconds") if not 0 <= value <= 30: raise ValueError(f"HSS_LOCAL_STAGE_DELAY_S={raw!r} is outside the range 0 to 30 seconds") _require_dev_stubs("HSS_LOCAL_STAGE_DELAY_S", env) diff --git a/backend/server/orchestrator/stubs/result.py b/backend/server/orchestrator/stubs/result.py index 8e0d54714..354e87197 100644 --- a/backend/server/orchestrator/stubs/result.py +++ b/backend/server/orchestrator/stubs/result.py @@ -28,6 +28,7 @@ ) from server.orchestrator.stubs._common import json_bytes, scripted_outcome, sha256, write_files from server.result import ar +from server.result.compose import FOOTPRINT from server.result.copy import FALLBACK, Catalogue, load_catalogue, render, select_prompt _PROFILES: frozenset[str] = frozenset({"A", "B", "C", "S"}) @@ -55,8 +56,7 @@ ), } -# The profile-C footprint, as the real composer's FOOTPRINT table holds it. -_FOOTPRINT_C = (0.7874, 1.0033, 0.5588) +# The profile-C footprint fallback is FOOTPRINT["C"] (real composer's table). _STANDOFF_M = 0.02 # The committed prompt catalogue; the production catalogue may not exist here. @@ -141,9 +141,11 @@ def _placement(profile: str, epoch: str | None) -> RecommendedPlacement: The stub scene is meter-anchored with scale 1.0, so the scene-to-ARKit transform is the identity: the box pose is the meter-frame box pose, and - the epoch comes from the capture packet's own ``epochs`` list. + the epoch comes from the capture packet's own ``epochs`` list. The + footprint comes from the real composer's ``FOOTPRINT`` table, so the + scripted proposal exercises the configured profile's product geometry. """ - width, height, depth = _FOOTPRINT_C + width, height, depth = FOOTPRINT.get(profile, FOOTPRINT["C"]) size = [width, height, depth] center = [0.6937, height / 2, depth / 2 + _STANDOFF_M] box_arkit = None @@ -175,6 +177,11 @@ def _packet_epoch(ctx: StageContext) -> str | None: def _packet_epochs(ctx: StageContext) -> list[str]: + """The packet's epoch ids, as the real composer feeds ``ar.epoch_id``. + + Packet ``epochs`` entries are objects like ``{"id": "e1"}``; plain strings + are tolerated for older hand-written packets. + """ try: raw = (Path(str(ctx.inputs["packet_dir"])) / "packet.json").read_text() packet = json.loads(raw) @@ -183,7 +190,13 @@ def _packet_epochs(ctx: StageContext) -> list[str]: epochs = packet.get("epochs") if isinstance(packet, dict) else None if not isinstance(epochs, list): return [] - return [str(item) for item in epochs if isinstance(item, str)] + ids = [] + for item in epochs: + if isinstance(item, str): + ids.append(item) + elif isinstance(item, dict) and isinstance(item.get("id"), str): + ids.append(item["id"]) + return ids def _views_needed() -> list[ViewNeededDevice]: diff --git a/backend/server/orchestrator/stubs/validate.py b/backend/server/orchestrator/stubs/validate.py index d50851d3b..7c023fac3 100644 --- a/backend/server/orchestrator/stubs/validate.py +++ b/backend/server/orchestrator/stubs/validate.py @@ -32,9 +32,13 @@ def _script_failed(ctx: StageContext) -> None: status is ``failed``: the validate stage failed while ``packet/validation.json`` says the images are unusable. The stand-in registers itself on the run record directly, because a failing stage's - ``StageResult`` is discarded. + ``StageResult`` is discarded. The bytes land at the same run-prefixed + storage key ``runner._publish`` uses for stage artifacts, so one run's + validation evidence can never be overwritten by a later capture's, and + ``coordinator._read_json`` resolves it from storage through the run + record's artifact map. """ - key = "packet/validation.json" + key = f"captures/{ctx.capture_id}/runs/{ctx.run_id}/packet/validation.json" body = json_bytes( { "imagesUsable": False, @@ -45,7 +49,7 @@ def _script_failed(ctx: StageContext) -> None: ) ctx.storage.put_bytes(key, body, "application/json") run = ctx.state.get_run(ctx.run_id) - run.artifacts[key] = key + run.artifacts["packet/validation.json"] = key ctx.state.put_run(run) raise RuntimeError( "local_scripted_failure: HSS_LOCAL_OUTCOME=failed ends the run on outcome row 1 " diff --git a/backend/tests/fixtures/app_states/eligible/events.json b/backend/tests/fixtures/app_states/eligible/events.json index 4d498bf47..e26345a49 100644 --- a/backend/tests/fixtures/app_states/eligible/events.json +++ b/backend/tests/fixtures/app_states/eligible/events.json @@ -2,7 +2,7 @@ "captureStatus": "complete", "events": [ { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "committed": 11, "listed": null, @@ -24,7 +24,7 @@ "type": "files_committed" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "code": "frames_blurry", "frames": [ @@ -36,7 +36,7 @@ "type": "hint" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "complete": true, "meterRead": "absent", @@ -46,13 +46,13 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": null, "errorCode": null, "gpu": null, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "validate", "status": "running" }, @@ -60,10 +60,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 0, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "validate", "status": "pending" }, @@ -71,7 +71,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "complete": false, "meterRead": "absent", @@ -86,10 +86,10 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "validate", "status": "running" }, @@ -97,11 +97,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "validate", "status": "done" }, @@ -109,11 +109,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "poses", "status": "running" }, @@ -121,11 +121,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "objects", "status": "running" }, @@ -133,10 +133,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "reads", "status": "running" }, @@ -144,12 +144,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "objects", "status": "done" }, @@ -157,12 +157,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "poses", "status": "done" }, @@ -170,11 +170,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "reads", "status": "done" }, @@ -182,10 +182,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "scale", "status": "running" }, @@ -193,11 +193,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "scale", "status": "done" }, @@ -205,11 +205,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "dense", "status": "running" }, @@ -217,12 +217,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "dense", "status": "done" }, @@ -230,10 +230,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "coverage", "status": "running" }, @@ -241,11 +241,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "coverage", "status": "done" }, @@ -253,10 +253,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "scene", "status": "running" }, @@ -264,11 +264,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "scene", "status": "done" }, @@ -276,10 +276,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "objects_3d", "status": "running" }, @@ -287,11 +287,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "objects_3d", "status": "done" }, @@ -299,10 +299,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "placement", "status": "running" }, @@ -310,10 +310,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "criteria", "status": "running" }, @@ -321,11 +321,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "criteria", "status": "done" }, @@ -333,11 +333,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.0, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "placement", "status": "done" }, @@ -345,10 +345,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "result", "status": "running" }, @@ -356,20 +356,20 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "kind": "eligible", - "runId": "run_0efe512c502a5f3b" + "runId": "run_ba609feb3ab55185" }, "seq": 30, "type": "verdict_ready" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_0efe512c502a5f3b", + "runId": "run_ba609feb3ab55185", "stage": "result", "status": "done" }, diff --git a/backend/tests/fixtures/app_states/eligible/result.json b/backend/tests/fixtures/app_states/eligible/result.json index 2bb00889f..c7807b1e3 100644 --- a/backend/tests/fixtures/app_states/eligible/result.json +++ b/backend/tests/fixtures/app_states/eligible/result.json @@ -11,7 +11,7 @@ "value": null }, "outcome": { - "arkitEpoch": null, + "arkitEpoch": "e1", "cableRoutePolylineArkit": null, "frameToArkit": null, "kind": "eligible", @@ -19,7 +19,31 @@ "profile": "C", "reasons": [], "recommendedPlacement": { - "boxArkitWorld": null, + "boxArkitWorld": { + "pose": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.6937, + 0.50165, + 0.2994, + 1.0 + ], + "size": [ + 0.7874, + 1.0033, + 0.5588 + ] + }, "boxSceneM": { "centerM": [ 0.6937, @@ -45,9 +69,9 @@ }, "viewsNeeded": [] }, - "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Miwia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZXUEZTTUJCWjVQQ0dFS1pYRDA1L3J1bnMvcnVuXzBlZmU1MTJjNTAyYTVmM2IvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.46c8a0df6373617e682be7f977ca2f1a94ec89e5d121540abe38748ff5ce51a2", - "reviewUrl": "/v1/captures/cap_01M521FWPFSMBBZ5PCGEKZXD05/review", - "runId": "run_0efe512c502a5f3b", + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE3NDc2Nywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyWTVaVjFHUVkxQTFTRE01OE5YNzJYL3J1bnMvcnVuX2JhNjA5ZmViM2FiNTUxODUvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.1022d38e5ec26e01cc412a8b3d4e67575691efc32aae73bf0b8c6c78efc08251", + "reviewUrl": "/v1/captures/cap_01M52Y5ZV1GQY1A1SDM58NX72X/review", + "runId": "run_ba609feb3ab55185", "scale": null, "site": null, "status": "complete", diff --git a/backend/tests/fixtures/app_states/failed/events.json b/backend/tests/fixtures/app_states/failed/events.json index fb8e0075d..30801e9cc 100644 --- a/backend/tests/fixtures/app_states/failed/events.json +++ b/backend/tests/fixtures/app_states/failed/events.json @@ -2,7 +2,7 @@ "captureStatus": "failed", "events": [ { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "committed": 11, "listed": null, @@ -24,7 +24,7 @@ "type": "files_committed" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "code": "frames_blurry", "frames": [ @@ -36,7 +36,7 @@ "type": "hint" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "complete": true, "meterRead": "absent", @@ -46,13 +46,13 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "attempt": 1, "durationS": null, "errorCode": null, "gpu": null, - "runId": "run_927815ea44260bbf", + "runId": "run_223b03a5e1e6beb8", "stage": "validate", "status": "running" }, @@ -60,10 +60,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "attempt": 0, - "runId": "run_927815ea44260bbf", + "runId": "run_223b03a5e1e6beb8", "stage": "validate", "status": "pending" }, @@ -71,7 +71,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "complete": false, "meterRead": "absent", @@ -86,10 +86,10 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "attempt": 1, - "runId": "run_927815ea44260bbf", + "runId": "run_223b03a5e1e6beb8", "stage": "validate", "status": "running" }, @@ -97,10 +97,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "attempt": 2, - "runId": "run_927815ea44260bbf", + "runId": "run_223b03a5e1e6beb8", "stage": "validate", "status": "running" }, @@ -108,11 +108,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "attempt": 2, "errorCode": "local_scripted_failure", - "runId": "run_927815ea44260bbf", + "runId": "run_223b03a5e1e6beb8", "stage": "validate", "status": "failed" }, @@ -120,7 +120,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:28Z", "data": { "code": "packet_invalid", "message": "HSS_LOCAL_OUTCOME=failed ends the run on outcome row 1 (validate failed, images unusable)", diff --git a/backend/tests/fixtures/app_states/failed/result.json b/backend/tests/fixtures/app_states/failed/result.json index d5c570c3d..46fb17559 100644 --- a/backend/tests/fixtures/app_states/failed/result.json +++ b/backend/tests/fixtures/app_states/failed/result.json @@ -14,8 +14,8 @@ "viewsNeeded": [] }, "previewUrl": null, - "reviewUrl": "/v1/captures/cap_01M521FXP3590453444NBF5AQF/review", - "runId": "run_927815ea44260bbf", + "reviewUrl": "/v1/captures/cap_01M52Y60WNH0MW5V316QF4P7KN/review", + "runId": "run_223b03a5e1e6beb8", "scale": null, "site": { "decision": "manual_review", diff --git a/backend/tests/fixtures/app_states/manual_review/events.json b/backend/tests/fixtures/app_states/manual_review/events.json index f9cc870fa..335ad44b2 100644 --- a/backend/tests/fixtures/app_states/manual_review/events.json +++ b/backend/tests/fixtures/app_states/manual_review/events.json @@ -2,7 +2,7 @@ "captureStatus": "manual_review", "events": [ { - "at": "2026-10-16T09:43:01Z", + "at": "2026-10-16T18:04:26Z", "data": { "committed": 11, "listed": null, @@ -24,7 +24,7 @@ "type": "files_committed" }, { - "at": "2026-10-16T09:43:01Z", + "at": "2026-10-16T18:04:26Z", "data": { "code": "frames_blurry", "frames": [ @@ -36,7 +36,7 @@ "type": "hint" }, { - "at": "2026-10-16T09:43:01Z", + "at": "2026-10-16T18:04:26Z", "data": { "complete": true, "meterRead": "absent", @@ -46,13 +46,13 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:01Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": null, "errorCode": null, "gpu": null, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "validate", "status": "running" }, @@ -60,10 +60,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 0, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "validate", "status": "pending" }, @@ -71,7 +71,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "complete": false, "meterRead": "absent", @@ -86,10 +86,10 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "validate", "status": "running" }, @@ -97,11 +97,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "validate", "status": "done" }, @@ -109,11 +109,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "poses", "status": "running" }, @@ -121,11 +121,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "objects", "status": "running" }, @@ -133,10 +133,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "reads", "status": "running" }, @@ -144,12 +144,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "objects", "status": "done" }, @@ -157,12 +157,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "poses", "status": "done" }, @@ -170,11 +170,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "reads", "status": "done" }, @@ -182,10 +182,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "scale", "status": "running" }, @@ -193,11 +193,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "scale", "status": "done" }, @@ -205,11 +205,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "dense", "status": "running" }, @@ -217,12 +217,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "dense", "status": "done" }, @@ -230,10 +230,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "coverage", "status": "running" }, @@ -241,11 +241,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "coverage", "status": "done" }, @@ -253,10 +253,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "scene", "status": "running" }, @@ -264,11 +264,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "scene", "status": "done" }, @@ -276,10 +276,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "objects_3d", "status": "running" }, @@ -287,11 +287,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "objects_3d", "status": "done" }, @@ -299,10 +299,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "placement", "status": "running" }, @@ -310,10 +310,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "criteria", "status": "running" }, @@ -321,11 +321,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "criteria", "status": "done" }, @@ -333,11 +333,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.0, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "placement", "status": "done" }, @@ -345,10 +345,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "result", "status": "running" }, @@ -356,20 +356,20 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "kind": "manual_review", - "runId": "run_1e16b0f7be5c5da8" + "runId": "run_df7d72b07f659db4" }, "seq": 30, "type": "verdict_ready" }, { - "at": "2026-10-16T09:43:02Z", + "at": "2026-10-16T18:04:26Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_1e16b0f7be5c5da8", + "runId": "run_df7d72b07f659db4", "stage": "result", "status": "done" }, diff --git a/backend/tests/fixtures/app_states/manual_review/result.json b/backend/tests/fixtures/app_states/manual_review/result.json index bf80ae688..af41ccc69 100644 --- a/backend/tests/fixtures/app_states/manual_review/result.json +++ b/backend/tests/fixtures/app_states/manual_review/result.json @@ -21,9 +21,9 @@ "recommendedPlacement": null, "viewsNeeded": [] }, - "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Miwia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZXMDlZU0dUWkdGRFo2UU1NRlozL3J1bnMvcnVuXzFlMTZiMGY3YmU1YzVkYTgvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.53340a965238d5c0a7026dff1a1850a3d27b1ea5c846922075f0ef675bc1e43d", - "reviewUrl": "/v1/captures/cap_01M521FW09YSGTZGFDZ6QMMFZ3/review", - "runId": "run_1e16b0f7be5c5da8", + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE3NDc2Niwia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyWTVaNzlGQUJHTUQ2NDFSMVg4V1o3L3J1bnMvcnVuX2RmN2Q3MmIwN2Y2NTlkYjQvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.152598eb13f0de3bac80d59b7ad3557db176315007aea22d88390c37aeea5f40", + "reviewUrl": "/v1/captures/cap_01M52Y5Z79FABGMD641R1X8WZ7/review", + "runId": "run_df7d72b07f659db4", "scale": null, "site": null, "status": "manual_review", diff --git a/backend/tests/fixtures/app_states/needs_more_photos/events.json b/backend/tests/fixtures/app_states/needs_more_photos/events.json index 638399071..66fcdd91e 100644 --- a/backend/tests/fixtures/app_states/needs_more_photos/events.json +++ b/backend/tests/fixtures/app_states/needs_more_photos/events.json @@ -2,7 +2,7 @@ "captureStatus": "needs_views", "events": [ { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "committed": 11, "listed": null, @@ -24,7 +24,7 @@ "type": "files_committed" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "code": "frames_blurry", "frames": [ @@ -36,7 +36,7 @@ "type": "hint" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "complete": true, "meterRead": "absent", @@ -46,13 +46,13 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": null, "errorCode": null, "gpu": null, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "validate", "status": "running" }, @@ -60,10 +60,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 0, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "validate", "status": "pending" }, @@ -71,7 +71,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "complete": false, "meterRead": "absent", @@ -86,10 +86,10 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "validate", "status": "running" }, @@ -97,11 +97,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "validate", "status": "done" }, @@ -109,11 +109,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "poses", "status": "running" }, @@ -121,11 +121,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "objects", "status": "running" }, @@ -133,10 +133,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "reads", "status": "running" }, @@ -144,12 +144,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "objects", "status": "done" }, @@ -157,12 +157,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "poses", "status": "done" }, @@ -170,11 +170,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "reads", "status": "done" }, @@ -182,10 +182,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "scale", "status": "running" }, @@ -193,11 +193,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "scale", "status": "done" }, @@ -205,11 +205,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "dense", "status": "running" }, @@ -217,12 +217,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "dense", "status": "done" }, @@ -230,10 +230,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "coverage", "status": "running" }, @@ -241,11 +241,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "coverage", "status": "done" }, @@ -253,10 +253,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "scene", "status": "running" }, @@ -264,11 +264,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "scene", "status": "done" }, @@ -276,10 +276,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "objects_3d", "status": "running" }, @@ -287,11 +287,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "objects_3d", "status": "done" }, @@ -299,10 +299,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "placement", "status": "running" }, @@ -310,10 +310,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "criteria", "status": "running" }, @@ -321,11 +321,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "criteria", "status": "done" }, @@ -333,11 +333,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.0, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "placement", "status": "done" }, @@ -345,10 +345,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "result", "status": "running" }, @@ -356,20 +356,20 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "kind": "needs_more_photos", - "runId": "run_b7d01d96e42e7812" + "runId": "run_b435700e614bf6b6" }, "seq": 30, "type": "verdict_ready" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_b7d01d96e42e7812", + "runId": "run_b435700e614bf6b6", "stage": "result", "status": "done" }, diff --git a/backend/tests/fixtures/app_states/needs_more_photos/result.json b/backend/tests/fixtures/app_states/needs_more_photos/result.json index 7e8e73c02..d1aa1b628 100644 --- a/backend/tests/fixtures/app_states/needs_more_photos/result.json +++ b/backend/tests/fixtures/app_states/needs_more_photos/result.json @@ -11,7 +11,7 @@ "value": null }, "outcome": { - "arkitEpoch": null, + "arkitEpoch": "e1", "cableRoutePolylineArkit": null, "frameToArkit": null, "kind": "needs_more_photos", @@ -51,9 +51,9 @@ } ] }, - "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Mywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZYMFlZWE5HUlBWQVFITUZYNEZLL3J1bnMvcnVuX2I3ZDAxZDk2ZTQyZTc4MTIvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.35dcaa965488d0774d4d9f6f270394319ac7df1726f68ce6bd2afdcd14c63a00", - "reviewUrl": "/v1/captures/cap_01M521FX0YYXNGRPVAQHMFX4FK/review", - "runId": "run_b7d01d96e42e7812", + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE3NDc2Nywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyWTYwNzA5V0hTUUdWSkE3M01QSkJSL3J1bnMvcnVuX2I0MzU3MDBlNjE0YmY2YjYvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.89caf174294df6418f3c4481e4156cc88defae5c2d7185fd1f039188311dd32f", + "reviewUrl": "/v1/captures/cap_01M52Y60709WHSQGVJA73MPJBR/review", + "runId": "run_b435700e614bf6b6", "scale": null, "site": null, "status": "needs_views", diff --git a/backend/tests/fixtures/app_states/not_eligible/events.json b/backend/tests/fixtures/app_states/not_eligible/events.json index c3bfa629d..a2fd592b3 100644 --- a/backend/tests/fixtures/app_states/not_eligible/events.json +++ b/backend/tests/fixtures/app_states/not_eligible/events.json @@ -2,7 +2,7 @@ "captureStatus": "complete", "events": [ { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "committed": 11, "listed": null, @@ -24,7 +24,7 @@ "type": "files_committed" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "code": "frames_blurry", "frames": [ @@ -36,7 +36,7 @@ "type": "hint" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "complete": true, "meterRead": "absent", @@ -46,13 +46,13 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": null, "errorCode": null, "gpu": null, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "validate", "status": "running" }, @@ -60,10 +60,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 0, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "validate", "status": "pending" }, @@ -71,7 +71,7 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "complete": false, "meterRead": "absent", @@ -86,10 +86,10 @@ "type": "capture_check" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "validate", "status": "running" }, @@ -97,11 +97,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "validate", "status": "done" }, @@ -109,11 +109,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "poses", "status": "running" }, @@ -121,11 +121,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "objects", "status": "running" }, @@ -133,10 +133,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "reads", "status": "running" }, @@ -144,12 +144,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "objects", "status": "done" }, @@ -157,12 +157,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "poses", "status": "done" }, @@ -170,11 +170,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "reads", "status": "done" }, @@ -182,10 +182,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "scale", "status": "running" }, @@ -193,11 +193,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "scale", "status": "done" }, @@ -205,11 +205,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "dense", "status": "running" }, @@ -217,12 +217,12 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, "gpu": "L4", - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "dense", "status": "done" }, @@ -230,10 +230,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "coverage", "status": "running" }, @@ -241,11 +241,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "coverage", "status": "done" }, @@ -253,10 +253,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "scene", "status": "running" }, @@ -264,11 +264,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "scene", "status": "done" }, @@ -276,10 +276,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "objects_3d", "status": "running" }, @@ -287,11 +287,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "objects_3d", "status": "done" }, @@ -299,10 +299,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "placement", "status": "running" }, @@ -310,10 +310,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "criteria", "status": "running" }, @@ -321,11 +321,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "criteria", "status": "done" }, @@ -333,11 +333,11 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.0, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "placement", "status": "done" }, @@ -345,10 +345,10 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "result", "status": "running" }, @@ -356,20 +356,20 @@ "type": "stage" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "kind": "not_eligible", - "runId": "run_4846998965a545ea" + "runId": "run_31315753d7cadfb3" }, "seq": 30, "type": "verdict_ready" }, { - "at": "2026-10-16T09:43:03Z", + "at": "2026-10-16T18:04:27Z", "data": { "attempt": 1, "durationS": 0.01, - "runId": "run_4846998965a545ea", + "runId": "run_31315753d7cadfb3", "stage": "result", "status": "done" }, diff --git a/backend/tests/fixtures/app_states/not_eligible/result.json b/backend/tests/fixtures/app_states/not_eligible/result.json index 6f0d71ac0..dfb457eb4 100644 --- a/backend/tests/fixtures/app_states/not_eligible/result.json +++ b/backend/tests/fixtures/app_states/not_eligible/result.json @@ -11,7 +11,7 @@ "value": null }, "outcome": { - "arkitEpoch": null, + "arkitEpoch": "e1", "cableRoutePolylineArkit": null, "frameToArkit": null, "kind": "not_eligible", @@ -21,9 +21,9 @@ "recommendedPlacement": null, "viewsNeeded": [] }, - "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE0NDY4Mywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyMUZYQkVDM0ZLWDZNUUFXWkE5RTREL3J1bnMvcnVuXzQ4NDY5OTg5NjVhNTQ1ZWEvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.7d11d88555a1b20b96a17e63555e2214a9413d17aaaa27a8bf8fb97383935ec7", - "reviewUrl": "/v1/captures/cap_01M521FXBEC3FKX6MQAWZA9E4D/review", - "runId": "run_4846998965a545ea", + "previewUrl": "http://testserver/v1/uploads/eyJjdCI6IiIsImV4cCI6MTc5MjE3NDc2Nywia2V5IjoiY2FwdHVyZXMvY2FwXzAxTTUyWTYwSE1KVERCTjJNODNLMFk4QldOL3J1bnMvcnVuXzMxMzE1NzUzZDdjYWRmYjMvcmVzdWx0L3ByZXZpZXcucGx5IiwibWQ1IjoiIiwib3AiOiJnZXQifQ.94b921409ce9e101ae73809659d347c782cc10bdb1248d2f5c31f6fba996a270", + "reviewUrl": "/v1/captures/cap_01M52Y60HMJTDBN2M83K0Y8BWN/review", + "runId": "run_31315753d7cadfb3", "scale": null, "site": null, "status": "complete", diff --git a/backend/tests/test_local_outcome_states.py b/backend/tests/test_local_outcome_states.py index ec8c7227f..dac622c5b 100644 --- a/backend/tests/test_local_outcome_states.py +++ b/backend/tests/test_local_outcome_states.py @@ -42,6 +42,7 @@ from server.contracts.result import ResultResponse from server.orchestrator.config import load_orch from server.orchestrator.outcomes import Facts, decide +from server.result.compose import FOOTPRINT REPO_ROOT = Path(__file__).resolve().parents[2] PACKET_FIXTURE = REPO_ROOT / "backend" / "data" / "fixtures" / "packets" / "mini-arkit" @@ -283,10 +284,11 @@ def test_hss_local_outcome_walks_the_app_to_each_state( assert placement is not None assert placement["footprintM"] == {"w": 0.7874, "h": 1.0033, "d": 0.5588} assert placement["startSM"] == 0.3 - # The fixture packet carries no ARKit epoch, so the proposal stays in - # scene metres: AR data only when the frames match. - assert payload["outcome"]["arkitEpoch"] is None - assert placement["boxArkitWorld"] is None + # The fixture packet carries exactly one ARKit epoch ("e1", an object + # with an id), so the proposal is anchored in ARKit world. + assert payload["outcome"]["arkitEpoch"] == "e1" + assert placement["boxArkitWorld"] is not None + assert placement["boxArkitWorld"]["size"] == [0.7874, 1.0033, 0.5588] if outcome == "needs_more_photos": view = payload["viewsNeeded"][0] assert view["kind"] == "band" @@ -335,6 +337,38 @@ def test_stage_delay_paces_the_stub_run(local_app) -> None: assert elapsed >= 12 * 0.03 - 0.05, f"12 stub stages slept nowhere near 0.36s ({elapsed:.2f}s)" +def test_failed_validation_artifact_is_scoped_to_its_run(local_app) -> None: + """Two failed captures in a row: each ends ``failed`` with its own + validation.json, at the run-prefixed key the stage publisher uses — a later + capture can never overwrite an earlier run's evidence.""" + root, packet, client, app, deps = local_app("failed") + first_capture, first_run, first = _run_capture(root, packet, client, app, "failed-1") + second_capture, second_run, second = _run_capture(root, packet, client, app, "failed-2") + assert first.status == "failed" + assert second.status == "failed" + for capture_id, run_id in ((first_capture, first_run), (second_capture, second_run)): + run = deps.state.get_run(run_id) + key = run.artifacts["packet/validation.json"] + assert key == f"captures/{capture_id}/runs/{run_id}/packet/validation.json" + doc = json.loads(deps.storage.get_bytes(key)) + assert doc["imagesUsable"] is False + + +@pytest.mark.parametrize("profile", ["A", "B", "C"]) +def test_eligible_footprint_follows_the_configured_profile(local_app, monkeypatch, profile) -> None: + """The scripted placement advertises the configured profile, so its footprint + must come from that profile's row in the real composer's FOOTPRINT table.""" + monkeypatch.setenv("RULES_PROFILE_DEFAULT", profile) + root, packet, client, app, deps = local_app("eligible") + capture_id, run_id, capture = _run_capture(root, packet, client, app, f"local-footprint-{profile}") + assert capture.status == "complete" + payload = client.get(f"/v1/captures/{capture_id}/result").json() + outcome = payload["outcome"] + assert outcome["profile"] == profile + width, height, depth = FOOTPRINT[profile] + assert outcome["recommendedPlacement"]["footprintM"] == {"w": width, "h": height, "d": depth} + + # --- loud refusals ----------------------------------------------------------- @@ -369,6 +403,20 @@ def test_local_delay_out_of_range_is_refused() -> None: ) +def test_local_delay_non_finite_is_refused() -> None: + """``float()`` accepts ``nan`` and ``inf``; refuse them by name with a + message that says the value must be finite, before the range comparison.""" + for raw in ("nan", "inf", "-inf"): + with pytest.raises(ValueError, match="not a finite number of seconds"): + load_orch( + { + "HSS_ORCH_STAGE_STUBS": "1", + "SMP_ENVIRONMENT": "dev", + "HSS_LOCAL_STAGE_DELAY_S": raw, + } + ) + + def test_local_settings_parse_when_dev_stage_stubs_are_on() -> None: cfg = load_orch( {