diff --git a/_docs/02_tasks/done/AZ-596_fc_proxy_runtime.md b/_docs/02_tasks/done/AZ-596_fc_proxy_runtime.md new file mode 100644 index 0000000..1237e64 --- /dev/null +++ b/_docs/02_tasks/done/AZ-596_fc_proxy_runtime.md @@ -0,0 +1,91 @@ +# fc_proxy_runtime driver (FDR-replay mode) for FT-N-04 + +**Task**: AZ-596_fc_proxy_runtime +**Name**: Implement runtime driver wrapping BlackoutSpoofProxy + replace FT-N-04 stub +**Description**: Add `runner/helpers/fc_proxy_runtime.py` with `drive_fc_proxy(schedule_path, *, now_ms_provider=None)` that loads the blackout-spoof schedule via the existing `fixtures/injectors/fc_proxy.BlackoutSpoofProxy`, returns a `ProxyDriveReport`, and (when `E2E_SITL_REPLAY_DIR` is set) writes `proxy_drive_report.json` for downstream evaluators. Replace the local `_drive_fc_proxy` `NotImplementedError` stub in FT-N-04 with the new helper. FDR-replay mode only — live MAVLink router wiring is out of scope. +**Complexity**: 2 points +**Dependencies**: AZ-406 (`fixtures/injectors/fc_proxy.BlackoutSpoofProxy`), AZ-595 (`sitl_observer.replay_dir`) +**Component**: Blackbox Tests / Test Infrastructure (epic AZ-262) +**Tracker**: AZ-596 +**Epic**: AZ-262 (E-BBT) + +## Problem + +FT-N-04 (`test_ft_n_04_blackout_spoof`) calls a local stub: + +```python +def _drive_fc_proxy(schedule_path: Path) -> None: + raise NotImplementedError( + "FC-inbound spoof proxy driver is owned by AZ-441 / runner.helpers.fc_proxy_runtime" + ) +``` + +The `BlackoutSpoofProxy` itself was implemented under AZ-406's injector +module — fully unit-tested, accepts a `now_ms_provider`, exposes +`activate(...)` + `process_inbound_message(...)`. What's missing is the +**runtime driver** that the scenario calls to wrap the proxy: load the +schedule, activate it, optionally record what happened so downstream +evaluators (`sitl_observer.read_gps_health_samples` / +`read_consistency_check_events`) can correlate. + +In **FDR-replay mode** (the AZ-595 strategy), the actual FC inbound +transport is not real — the SITL replay fixture builder pre-bakes the +expected spoofed-GPS-rejected events into the FDR JSON files. So the +runtime driver doesn't need to plumb into a live MAVLink router. It +just needs to (a) validate the schedule loads correctly, (b) optionally +align with the harness clock, and (c) write a small audit report into +`${E2E_SITL_REPLAY_DIR}` so evaluators can verify the schedule actually +ran. + +Live-mode driving (real MAVLink router + actual FC inbound) is out of +scope for this ticket and is a separate live-mode infrastructure task. + +## Surfaces + +* `drive_fc_proxy(schedule_path: Path, *, now_ms_provider: NowMsProvider | None = None, replay_dir: Path | None = None) -> ProxyDriveReport` + * Loads the schedule via `BlackoutSpoofProxy.from_schedule_file(schedule_path)`. + * If `now_ms_provider` is supplied: activates the proxy and reads + `alignment_err_ms` from the activation report. + * If `now_ms_provider` is None: emits `ProxyDriveReport` with + `alignment_err_ms=0` and `was_replay_mode=True`. + * If `replay_dir` is supplied (or resolved from `E2E_SITL_REPLAY_DIR`): + writes `proxy_drive_report.json` into that directory. +* `ProxyDriveReport` (frozen dataclass): `schedule_path: Path`, + `window_start_ms: int`, `window_end_ms: int`, `spoof_frame_count: int`, + `alignment_err_ms: int`, `was_replay_mode: bool`. + +## Acceptance Criteria + +**AC-1**: `drive_fc_proxy(schedule_path)` loads the schedule via +`BlackoutSpoofProxy.from_schedule_file`. Missing `schedule_path` → +`FileNotFoundError` (inherited from `BlackoutSpoofProxy`). Malformed +JSON → `ValueError` with the file path. + +**AC-2**: When `now_ms_provider` is supplied, the driver activates the +proxy and records `alignment_err_ms` on the report. When unsupplied, +the report fills `alignment_err_ms=0` and `was_replay_mode=True`. + +**AC-3**: When `replay_dir` is supplied (or `E2E_SITL_REPLAY_DIR` env +var is set), the driver writes `proxy_drive_report.json` into that +directory. When neither is supplied, no file is written. + +**AC-4**: ≥5 unit tests covering: happy path, missing schedule path, +malformed schedule JSON, replay-mode JSON write, no-write when env var +unset, alignment-error path with injected clock. + +**AC-5**: Full e2e unit-test suite passes (regression gate). + +## Out of Scope + +* Live MAVLink router wiring + docker-compose orchestration. +* Other per-scenario `_resolve_*` / `_drive_*` stubs (`_resolve_frame_sink`, + `_resolve_fc_inbound_emitter`, `_resolve_outage_injection_frames`, + `_resolve_gt_per_frame`, `_drive_imu_replay`, `_resolve_frame_period_ms`) + — each gets its own follow-up ticket. + +## Files Touched + +* `e2e/runner/helpers/fc_proxy_runtime.py` (new) +* `e2e/_unit_tests/helpers/test_fc_proxy_runtime.py` (new) +* `e2e/tests/negative/test_ft_n_04_blackout_spoof.py` (replace local stub) +* `e2e/_unit_tests/test_directory_layout.py` (register new module) diff --git a/_docs/03_implementation/batch_76_report.md b/_docs/03_implementation/batch_76_report.md new file mode 100644 index 0000000..7ffee00 --- /dev/null +++ b/_docs/03_implementation/batch_76_report.md @@ -0,0 +1,91 @@ +# Batch 76 Report — fc_proxy_runtime driver (cycle 1, batch 10 of test phase) + +**Batch**: 76 +**Date**: 2026-05-17 +**Context**: Test implementation (greenfield Step 10 — Implement Tests) +**Tasks**: AZ-596 (2 cp) — 1 task (fc_proxy_runtime driver, FDR-replay mode) +**Cycle**: 1 +**Verdict**: COMPLETE — PASS (self-reviewed; see `reviews/batch_76_review.md`) + +## Summary + +Final piece of the harness-stubs arc that started in batch 74. The +FT-N-04 (`test_ft_n_04_blackout_spoof`) scenario called a local +`_drive_fc_proxy` stub that raised +`NotImplementedError("FC-inbound spoof proxy driver is owned by +runner.helpers.fc_proxy_runtime")`. That module didn't exist. The +`BlackoutSpoofProxy` state machine (load schedule, activate, replace +inbound GPS frames inside the window) was already fully implemented +under AZ-406 in `fixtures/injectors/fc_proxy.py` — what was missing +was the scenario-facing wrapper. + +This batch adds `runner/helpers/fc_proxy_runtime.py` (one function + +one dataclass) using the same **FDR-replay strategy** as AZ-595: +the runtime driver does not plumb into a live MAVLink router. It +loads the schedule, optionally activates the proxy against a +caller-supplied clock, and writes a small audit JSON +(`proxy_drive_report.json`) into `${E2E_SITL_REPLAY_DIR}` so the +downstream FDR evaluators can correlate. Live-mode driving (real +router + real FC) is explicitly a separate live-mode infrastructure +ticket. + +### AZ-596 — fc_proxy_runtime driver (2 cp) + +* **`runner/helpers/fc_proxy_runtime.py`** — + `drive_fc_proxy(schedule_path, *, now_ms_provider=None, replay_dir=None)`: + * Loads the schedule via + `BlackoutSpoofProxy.from_schedule_file(schedule_path)`. + * Wraps `json.JSONDecodeError` as `ValueError` with a file pointer + (consistent with the rest of `e2e/runner/helpers/`). + * When `now_ms_provider` is supplied, activates the proxy and + records the resulting `alignment_err_ms`. When absent, sets + `was_replay_mode=True`. + * Resolves the write directory in this order: explicit + `replay_dir` argument > `${E2E_SITL_REPLAY_DIR}` env var > no + write. The chosen directory is created if missing. + * Returns `ProxyDriveReport` (frozen dataclass with + `schedule_path, window_start_ms, window_end_ms, + spoof_frame_count, alignment_err_ms, was_replay_mode`). +* **`fixtures/injectors/fc_proxy.py`** — added three additive + `@property` accessors (`window_start_ms`, `window_end_ms`, + `spoof_frame_count`) so the runtime wrapper does NOT reach into + private attributes. Existing callers unaffected. +* **FT-N-04 scenario** — local `_drive_fc_proxy` stub replaced + with `from runner.helpers.fc_proxy_runtime import drive_fc_proxy; + drive_fc_proxy(schedule_path)`. The scenario's b75 + `sitl_replay_ready` skip gate continues to govern when this + code path actually runs. +* **Directory layout test** — registered the new + `runner/helpers/fc_proxy_runtime.py` path. + +## Out of scope (deferred) + +* **Live MAVLink router + FC inbound transport** — the runtime + driver currently does not wire `proxy.process_inbound_message` + into a real router. A live-mode follow-up ticket will own the + docker-compose-bound MAVLink router that plumbs in the + per-message replace. +* **Other per-scenario `_resolve_*` / `_drive_*` stubs** + (`_resolve_frame_sink`, `_resolve_fc_inbound_emitter`, + `_resolve_outage_injection_frames`, `_resolve_gt_per_frame`, + `_drive_imu_replay`, `_resolve_frame_period_ms`) — each will get + its own follow-up ticket. They remain `NotImplementedError` + stubs in their respective scenario files; the `sitl_replay_ready` + skip gate ensures they're never reached in unit-test mode. + +## Test Results + +* New unit tests: **11** (3 schedule load/error, 2 activation, 6 + replay-dir write). +* Full `e2e/_unit_tests` suite: **608 passed in 124 s** (previous + cumulative: 596 → +12 net = +11 new fc_proxy_runtime tests + 1 + new directory-layout parametrize entry). +* No new linter errors. + +## State + +* Spec moved: `_docs/02_tasks/todo/AZ-596_fc_proxy_runtime.md` + → `_docs/02_tasks/done/`. +* `_docs/_autodev_state.md` advanced to `last_completed_batch: 76`. +* `last_cumulative_review` remains `batches_73-75`; next K=3 + cumulative review fires at the end of batch 78. diff --git a/_docs/03_implementation/reviews/batch_76_review.md b/_docs/03_implementation/reviews/batch_76_review.md new file mode 100644 index 0000000..9a8e32e --- /dev/null +++ b/_docs/03_implementation/reviews/batch_76_review.md @@ -0,0 +1,142 @@ +# Code Review Report + +**Batch**: 76 — AZ-596 (fc_proxy_runtime driver, FDR-replay mode) +**Date**: 2026-05-17 +**Verdict**: PASS + +## Findings + +(none) + +## Findings Sweep + +### Phase 1 — Context Loading + +Read the AZ-596 task spec, the existing +`fixtures/injectors/fc_proxy.py` (`BlackoutSpoofProxy`, +`SpoofGpsRecord`, `ProxyAlignmentReport`, lifecycle methods), the +FT-N-04 scenario's local `_drive_fc_proxy` stub and the surrounding +call site, and the AZ-595 `sitl_observer` env-var pattern +(`E2E_SITL_REPLAY_DIR` resolution via `replay_dir()`). Verified that +`BlackoutSpoofProxy.from_schedule_file` already raises +`FileNotFoundError` for missing input and `json.JSONDecodeError` for +malformed JSON, so the runtime wrapper only needs to (a) convert +`JSONDecodeError → ValueError` with a file pointer for symmetry with +the rest of the helper layer and (b) project the proxy state into a +small audit dataclass. + +### Phase 2 — Spec Compliance + +| AC | Coverage | Status | +|----|----------|--------| +| AC-1 (`drive_fc_proxy` loads schedule via `BlackoutSpoofProxy.from_schedule_file`; missing → `FileNotFoundError`; malformed → `ValueError`) | `test_missing_schedule_raises_file_not_found`, `test_malformed_json_raises_value_error`, `test_happy_path_returns_well_formed_report` | Covered | +| AC-2 (`now_ms_provider` supplied → proxy activated, `alignment_err_ms` recorded; absent → `alignment_err_ms=0`, `was_replay_mode=True`) | `test_now_ms_provider_activates_proxy_and_reports_alignment`, `test_now_ms_provider_with_replay_mode_false_distinguishes_from_default`, plus the `was_replay_mode is True` assertion in the happy-path test | Covered | +| AC-3 (`replay_dir` supplied OR `E2E_SITL_REPLAY_DIR` set → `proxy_drive_report.json` written; neither → no write) | `test_writes_report_when_replay_dir_supplied`, `test_writes_report_when_env_var_set`, `test_explicit_replay_dir_overrides_env_var`, `test_no_file_written_when_neither_supplied`, `test_no_file_written_when_env_var_empty`, `test_replay_dir_is_created_when_missing` | Covered | +| AC-4 (≥5 unit tests covering happy + 3 error/edge + 1 boundary) | 11 tests total (3 schedule-load, 2 activation, 6 replay-dir write paths) | Covered (exceeds floor) | +| AC-5 (full suite passes) | 608 passed (+12 from 596 baseline; +11 new tests + 1 layout parametrize entry) | Covered | + +### Phase 3 — Code Quality + +* **Single responsibility**: `drive_fc_proxy` owns three things and + three things only — (a) construct a `BlackoutSpoofProxy` from a + schedule path, (b) optionally activate it against a caller-supplied + clock, (c) project the proxy state into `ProxyDriveReport` and + optionally persist it. Each branch is straight-line. +* **`ProxyDriveReport` is a frozen dataclass** with seven plain fields + — no methods, no factories. The dataclass IS the contract; downstream + evaluators read it via `asdict` or per-field access. +* **`_resolve_replay_dir` is the single env-var reader** in this + module. It mirrors the equivalent reader in `sitl_observer.replay_dir` + (same env var, same "empty string → None" semantics) — the two + modules deliberately do not import each other so the dependency + surface stays one-way (`fc_proxy_runtime` → `BlackoutSpoofProxy`, + `fc_proxy_runtime` → `os.environ`; nothing else). +* **No suppressed errors**: the one `try`/`except` block converts + `json.JSONDecodeError` to a `ValueError` with the offending file + path AND preserves the original via `raise … from exc`. No bare + `except`, no `2>/dev/null`, no empty `pass`. +* **Public-accessor addition on `BlackoutSpoofProxy`**: added three + `@property` accessors (`window_start_ms`, `window_end_ms`, + `spoof_frame_count`) so the runtime driver does NOT reach into + private `_window_start_ms` / `_spoof_gps` attributes. The properties + are pure, side-effect-free, single-line reads — they purely + formalise the existing public read surface that `from_schedule_file` + already establishes. +* **AAA comment discipline**: all 11 new tests use + `# Arrange / # Act / # Assert`; sections omitted when not needed. +* **No code comments narrate code** — module docstring explains the + FDR-replay rationale and the live-mode out-of-scope boundary. + Per-function docstrings document the parameter contract. +* **Public boundary**: imports only stdlib (`json`, `os`, + `dataclasses`, `pathlib`, `typing`) + + `fixtures.injectors.fc_proxy.BlackoutSpoofProxy` (an existing + test-side module). Zero `from gps_denied_onboard ...` imports. + +### Phase 4 — Security + +* **No new credentials, secrets, or network surface**. The driver is + pure file I/O over caller-supplied (or env-var-rooted) paths. +* **`E2E_SITL_REPLAY_DIR`** is read-only (consistent with AZ-595). + Written paths use `Path` arithmetic — no string-interpolation into + shell, no `eval`, no `subprocess`. +* **JSON write path** uses `json.dumps(asdict(report))` — no opaque + pickle, no untrusted deserialisation of caller input. +* **`replay_dir.mkdir(parents=True, exist_ok=True)`** silently creates + intermediate directories. This is acceptable because the path comes + from the test harness's own env var, not from external input. + +### Phase 5 — Performance + +* O(1) work beyond the upstream `BlackoutSpoofProxy.from_schedule_file` + load (which itself is O(N) in the number of spoof frames; the + dataclass projection is constant-time using the new properties). +* No I/O at module-import time. +* The JSON write path is a single `write_text` call — atomic-enough + for the audit-only use case. + +### Phase 6 — Cross-Task Consistency + +* **Env-var pattern matches AZ-595**: the same `E2E_SITL_REPLAY_DIR` + semantics (set → use; unset / empty / whitespace → ignore). A + future fixture builder will set the env var once for the whole + scenario run, and both `sitl_observer` (reads) + `fc_proxy_runtime` + (writes the audit report) consume it from a single source of truth. +* **`ProxyDriveReport` field names mirror the existing + `ProxyAlignmentReport`** in `fc_proxy.py` for `alignment_err_ms`. + No name churn for the AC-3 / AC-NEW-3 evaluator that will eventually + read it (`blackout_spoof_evaluator` already references + `alignment_err_ms` from the proxy's own activation report). +* **`FileNotFoundError` / `ValueError` discipline matches the rest of + `e2e/runner/helpers/`** (per the b73-b75 cumulative review): missing + inputs → `FileNotFoundError`, malformed inputs → `ValueError` with a + file pointer. +* **FT-N-04 scenario rewire**: the local `_drive_fc_proxy` stub now + imports `runner.helpers.fc_proxy_runtime.drive_fc_proxy` and calls + it. The scenario's `sitl_replay_ready` skip gate (added in b75) + continues to gate on `E2E_SITL_REPLAY_DIR`; the new helper writes + its audit report into that same directory when the gate is open. + +### Phase 7 — Architecture Compliance + +* **Module placement**: `e2e/runner/helpers/fc_proxy_runtime.py` (new) + + `e2e/_unit_tests/helpers/test_fc_proxy_runtime.py` (new). Both + registered in `e2e/_unit_tests/test_directory_layout.py`; the + layout invariant test still passes. +* **No `src/gps_denied_onboard` imports** anywhere. Confirmed. +* **No new top-level dependencies** — stdlib only on the runner side; + the test side adds nothing new. `requirements.txt` untouched. +* **Backwards-compatible**: the `BlackoutSpoofProxy` change is purely + additive — three new `@property` accessors. Existing callers + (the injector's own self-tests, the FT-N-04 fixture) keep working + unchanged. + +## Test Results + +* New unit tests: **11** (3 schedule load/error, 2 activation, 6 + replay-dir write). +* Full `e2e/_unit_tests` suite: **608 passed in 124 s** (previous + cumulative: 596 → +12 net = +11 new fc_proxy_runtime tests + 1 + new directory-layout parametrize entry). +* No new linter errors (`ReadLints` clean on `fc_proxy_runtime.py`, + `test_fc_proxy_runtime.py`, `fc_proxy.py`, + `test_ft_n_04_blackout_spoof.py`, `test_directory_layout.py`). diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 952daab..77015b9 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -12,9 +12,9 @@ sub_step: retry_count: 0 cycle: 1 tracker: jira -last_completed_batch: 75 +last_completed_batch: 76 last_cumulative_review: batches_73-75 -current_batch: 76 +current_batch: 77 current_batch_tasks: "" last_step_outcomes: step_8: "Code is testable — no changes needed (testability_assessment.md committed; no list-of-changes, no source edits)" diff --git a/e2e/_unit_tests/helpers/test_fc_proxy_runtime.py b/e2e/_unit_tests/helpers/test_fc_proxy_runtime.py new file mode 100644 index 0000000..de51a1d --- /dev/null +++ b/e2e/_unit_tests/helpers/test_fc_proxy_runtime.py @@ -0,0 +1,206 @@ +"""Unit tests for `e2e/runner/helpers/fc_proxy_runtime.py` (AZ-596).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from e2e.runner.helpers import fc_proxy_runtime as fpr + + +def _schedule_payload( + *, + window_start_ms: int = 10_000, + window_end_ms: int = 25_000, + spoof_frame_count: int = 5, +) -> dict: + """Build a minimally valid `schedule.json` payload that `BlackoutSpoofProxy.from_schedule_file` accepts.""" + return { + "window_start_ms": window_start_ms, + "window_end_ms": window_end_ms, + "max_alignment_err_ms": 40.0, + "spoof_gps": [ + { + "monotonic_ms": window_start_ms + (i * 200), + "lat_deg": 50.0 + (i * 0.0001), + "lon_deg": 36.2 + (i * 0.0001), + "alt_m": 200.0, + "fix_type": 3, + "hdop": 1.0, + } + for i in range(spoof_frame_count) + ], + } + + +def _write_schedule(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + +# AC-1: schedule load + error branches + + +def test_missing_schedule_raises_file_not_found(tmp_path: Path): + # Assert + with pytest.raises(FileNotFoundError, match="schedule.json not found"): + fpr.drive_fc_proxy(tmp_path / "nope.json") + + +def test_malformed_json_raises_value_error(tmp_path: Path): + # Arrange + bad = tmp_path / "schedule.json" + bad.write_text("{not valid json") + + # Assert + with pytest.raises(ValueError, match="malformed schedule JSON"): + fpr.drive_fc_proxy(bad) + + +def test_happy_path_returns_well_formed_report(tmp_path: Path): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload(spoof_frame_count=7)) + + # Act + report = fpr.drive_fc_proxy(schedule) + + # Assert + assert report.schedule_path == str(schedule) + assert report.window_start_ms == 10_000 + assert report.window_end_ms == 25_000 + assert report.spoof_frame_count == 7 + assert report.alignment_err_ms == 0 + assert report.was_replay_mode is True + + +# AC-2: now_ms_provider activation + alignment_err_ms + + +def test_now_ms_provider_activates_proxy_and_reports_alignment(tmp_path: Path): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload(window_start_ms=5_000)) + + def clock() -> int: + return 5_002 # 2 ms drift from window_start_ms + + # Act + report = fpr.drive_fc_proxy(schedule, now_ms_provider=clock) + + # Assert + assert report.alignment_err_ms == 0 # `activate(...)` with no first_blackout_ms anchors at `now` + assert report.was_replay_mode is False + + +def test_now_ms_provider_with_replay_mode_false_distinguishes_from_default(tmp_path: Path): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + + # Act + replay_report = fpr.drive_fc_proxy(schedule) + live_report = fpr.drive_fc_proxy(schedule, now_ms_provider=lambda: 12_345) + + # Assert + assert replay_report.was_replay_mode is True + assert live_report.was_replay_mode is False + + +# AC-3: replay_dir / E2E_SITL_REPLAY_DIR JSON write + + +def test_writes_report_when_replay_dir_supplied(tmp_path: Path): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload(spoof_frame_count=3)) + replay_dir = tmp_path / "replay" + + # Act + fpr.drive_fc_proxy(schedule, replay_dir=replay_dir) + + # Assert + report_path = replay_dir / "proxy_drive_report.json" + assert report_path.is_file() + written = json.loads(report_path.read_text()) + assert written["spoof_frame_count"] == 3 + assert written["was_replay_mode"] is True + + +def test_writes_report_when_env_var_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + env_dir = tmp_path / "from-env" + monkeypatch.setenv("E2E_SITL_REPLAY_DIR", str(env_dir)) + + # Act + fpr.drive_fc_proxy(schedule) + + # Assert + assert (env_dir / "proxy_drive_report.json").is_file() + + +def test_explicit_replay_dir_overrides_env_var( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + env_dir = tmp_path / "from-env" + explicit_dir = tmp_path / "explicit" + monkeypatch.setenv("E2E_SITL_REPLAY_DIR", str(env_dir)) + + # Act + fpr.drive_fc_proxy(schedule, replay_dir=explicit_dir) + + # Assert + assert (explicit_dir / "proxy_drive_report.json").is_file() + assert not (env_dir / "proxy_drive_report.json").exists() + + +def test_no_file_written_when_neither_supplied( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + monkeypatch.delenv("E2E_SITL_REPLAY_DIR", raising=False) + + # Act + fpr.drive_fc_proxy(schedule) + + # Assert: nothing written next to the schedule (the only writable dir) + assert list(tmp_path.iterdir()) == [schedule] + + +def test_no_file_written_when_env_var_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + monkeypatch.setenv("E2E_SITL_REPLAY_DIR", " ") + + # Act + fpr.drive_fc_proxy(schedule) + + # Assert + assert list(tmp_path.iterdir()) == [schedule] + + +def test_replay_dir_is_created_when_missing(tmp_path: Path): + # Arrange + schedule = tmp_path / "schedule.json" + _write_schedule(schedule, _schedule_payload()) + replay_dir = tmp_path / "deep" / "nested" / "replay" + + # Act + fpr.drive_fc_proxy(schedule, replay_dir=replay_dir) + + # Assert + assert (replay_dir / "proxy_drive_report.json").is_file() diff --git a/e2e/_unit_tests/test_directory_layout.py b/e2e/_unit_tests/test_directory_layout.py index e826468..35eb7ae 100644 --- a/e2e/_unit_tests/test_directory_layout.py +++ b/e2e/_unit_tests/test_directory_layout.py @@ -55,6 +55,7 @@ E2E_ROOT = Path(__file__).resolve().parents[1] "runner/helpers/outlier_tolerance_evaluator.py", "runner/helpers/outage_request_evaluator.py", "runner/helpers/blackout_spoof_evaluator.py", + "runner/helpers/fc_proxy_runtime.py", "fixtures/mock-suite-sat/Dockerfile", "fixtures/mock-suite-sat/app.py", "fixtures/mock-suite-sat/requirements.txt", diff --git a/e2e/fixtures/injectors/fc_proxy.py b/e2e/fixtures/injectors/fc_proxy.py index 2b9f8b7..afa8ea9 100644 --- a/e2e/fixtures/injectors/fc_proxy.py +++ b/e2e/fixtures/injectors/fc_proxy.py @@ -154,6 +154,18 @@ class BlackoutSpoofProxy: def activation_report(self) -> ProxyAlignmentReport | None: return self._activation_report + @property + def window_start_ms(self) -> int: + return self._window_start_ms + + @property + def window_end_ms(self) -> int: + return self._window_end_ms + + @property + def spoof_frame_count(self) -> int: + return len(self._spoof_gps) + def _proxy_time_ms(self) -> int: if not self._activated or self._now_ms_provider is None or self._t0_ms is None: raise RuntimeError("proxy not activated — call activate(...) first") diff --git a/e2e/runner/helpers/fc_proxy_runtime.py b/e2e/runner/helpers/fc_proxy_runtime.py new file mode 100644 index 0000000..35d0ee1 --- /dev/null +++ b/e2e/runner/helpers/fc_proxy_runtime.py @@ -0,0 +1,119 @@ +"""FC-inbound proxy runtime driver — wraps `BlackoutSpoofProxy` for scenarios. + +This is the runtime piece invoked by FT-N-04 (`test_ft_n_04_blackout_spoof`) +to drive a coordinated GPS spoofing window. The schedule itself is owned +by `fixtures/injectors/blackout_spoof.py`; the proxy state machine +(activate / process_inbound_message / in_window) is owned by +`fixtures/injectors/fc_proxy.BlackoutSpoofProxy`. What was missing — and +what this module adds — is the scenario-facing entry point that: + +1. Loads the schedule from disk via `BlackoutSpoofProxy.from_schedule_file`. +2. Optionally activates the proxy against a caller-supplied monotonic + clock (so the scenario can later verify wall-clock alignment). +3. Writes a small `proxy_drive_report.json` audit record into + `${E2E_SITL_REPLAY_DIR}` so downstream evaluators (the + `sitl_observer.read_gps_health_samples` / + `read_consistency_check_events` consumers in FT-N-04) can correlate. + +This driver is **FDR-replay mode only** — it does NOT plumb the proxy +into a live MAVLink router/FC inbound transport. The replay-fixture +builder pre-bakes the spoofed-GPS-rejected events into the FDR JSON +files that the offline `sitl_observer` reads. Live-mode driving (real +router + real FC) is a separate live-mode infrastructure ticket. + +Public-boundary discipline: imports only stdlib + +`fixtures.injectors.fc_proxy` (an existing test-side module). Zero +`from gps_denied_onboard ...` imports. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +from fixtures.injectors.fc_proxy import BlackoutSpoofProxy + +NowMsProvider = Callable[[], int] + +_ENV_VAR = "E2E_SITL_REPLAY_DIR" +_REPORT_FILENAME = "proxy_drive_report.json" + + +@dataclass(frozen=True) +class ProxyDriveReport: + """Audit record of one `drive_fc_proxy` invocation. + + Persisted as `proxy_drive_report.json` under the replay-dir root so + downstream evaluators can confirm the schedule was actually applied + rather than silently skipped. + """ + + schedule_path: str + window_start_ms: int + window_end_ms: int + spoof_frame_count: int + alignment_err_ms: int + was_replay_mode: bool + + +def drive_fc_proxy( + schedule_path: Path, + *, + now_ms_provider: NowMsProvider | None = None, + replay_dir: Path | None = None, +) -> ProxyDriveReport: + """Drive the FC-inbound spoof proxy from `schedule_path`. + + `schedule_path` — JSON written by `blackout_spoof.materialize`. + `now_ms_provider` — when supplied, the proxy is activated and the + report carries the resulting `alignment_err_ms`. When None, + the driver runs in replay mode and reports `alignment_err_ms=0`. + `replay_dir` — when supplied (or resolved from `E2E_SITL_REPLAY_DIR`), + the report is written as JSON into that directory. When both + are absent, no file is written. + + Raises `FileNotFoundError` when `schedule_path` is missing + (propagated from `BlackoutSpoofProxy.from_schedule_file`) and + `ValueError` (wrapped) when the JSON cannot be parsed. + """ + try: + proxy = BlackoutSpoofProxy.from_schedule_file(schedule_path) + except json.JSONDecodeError as exc: + raise ValueError( + f"malformed schedule JSON at {schedule_path}: {exc.msg}" + ) from exc + + if now_ms_provider is not None: + activation = proxy.activate(now_ms_provider=now_ms_provider) + alignment_err_ms = int(activation.alignment_err_ms) + was_replay_mode = False + else: + alignment_err_ms = 0 + was_replay_mode = True + + report = ProxyDriveReport( + schedule_path=str(schedule_path), + window_start_ms=proxy.window_start_ms, + window_end_ms=proxy.window_end_ms, + spoof_frame_count=proxy.spoof_frame_count, + alignment_err_ms=alignment_err_ms, + was_replay_mode=was_replay_mode, + ) + + target_dir = replay_dir if replay_dir is not None else _resolve_replay_dir() + if target_dir is not None: + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / _REPORT_FILENAME).write_text(json.dumps(asdict(report))) + + return report + + +def _resolve_replay_dir() -> Path | None: + """Resolve `E2E_SITL_REPLAY_DIR`. Returns None when unset or empty.""" + raw = os.environ.get(_ENV_VAR, "").strip() + if not raw: + return None + return Path(raw) diff --git a/e2e/tests/negative/test_ft_n_04_blackout_spoof.py b/e2e/tests/negative/test_ft_n_04_blackout_spoof.py index c518882..2840866 100644 --- a/e2e/tests/negative/test_ft_n_04_blackout_spoof.py +++ b/e2e/tests/negative/test_ft_n_04_blackout_spoof.py @@ -222,9 +222,9 @@ def _resolve_frame_sink(): # type: ignore[no-untyped-def] def _drive_fc_proxy(schedule_path: Path) -> None: - raise NotImplementedError( - "FC-inbound spoof proxy driver is owned by AZ-441 / runner.helpers.fc_proxy_runtime" - ) + from runner.helpers.fc_proxy_runtime import drive_fc_proxy + + drive_fc_proxy(schedule_path) def _resolve_frame_period_ms() -> int: