mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 09:01:14 +00:00
[AZ-596] Batch 76: fc_proxy_runtime driver (FDR-replay mode)
Add `runner/helpers/fc_proxy_runtime.py` wrapping the existing
`BlackoutSpoofProxy` (AZ-406) with a scenario-facing `drive_fc_proxy`
entry point. FDR-replay mode only: loads `schedule.json`, optionally
activates the proxy against a caller clock for alignment verification,
and writes a `proxy_drive_report.json` audit record into
`${E2E_SITL_REPLAY_DIR}` for downstream evaluators.
Replaces the local `_drive_fc_proxy` stub in FT-N-04. Adds 3
@property accessors on `BlackoutSpoofProxy` so the wrapper does not
reach into private attributes. +11 unit tests (608 total, up from
596). Live-mode router wiring remains out of scope (future ticket).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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`).
|
||||||
@@ -12,9 +12,9 @@ sub_step:
|
|||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
last_completed_batch: 75
|
last_completed_batch: 76
|
||||||
last_cumulative_review: batches_73-75
|
last_cumulative_review: batches_73-75
|
||||||
current_batch: 76
|
current_batch: 77
|
||||||
current_batch_tasks: ""
|
current_batch_tasks: ""
|
||||||
last_step_outcomes:
|
last_step_outcomes:
|
||||||
step_8: "Code is testable — no changes needed (testability_assessment.md committed; no list-of-changes, no source edits)"
|
step_8: "Code is testable — no changes needed (testability_assessment.md committed; no list-of-changes, no source edits)"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -55,6 +55,7 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
"runner/helpers/outlier_tolerance_evaluator.py",
|
"runner/helpers/outlier_tolerance_evaluator.py",
|
||||||
"runner/helpers/outage_request_evaluator.py",
|
"runner/helpers/outage_request_evaluator.py",
|
||||||
"runner/helpers/blackout_spoof_evaluator.py",
|
"runner/helpers/blackout_spoof_evaluator.py",
|
||||||
|
"runner/helpers/fc_proxy_runtime.py",
|
||||||
"fixtures/mock-suite-sat/Dockerfile",
|
"fixtures/mock-suite-sat/Dockerfile",
|
||||||
"fixtures/mock-suite-sat/app.py",
|
"fixtures/mock-suite-sat/app.py",
|
||||||
"fixtures/mock-suite-sat/requirements.txt",
|
"fixtures/mock-suite-sat/requirements.txt",
|
||||||
|
|||||||
@@ -154,6 +154,18 @@ class BlackoutSpoofProxy:
|
|||||||
def activation_report(self) -> ProxyAlignmentReport | None:
|
def activation_report(self) -> ProxyAlignmentReport | None:
|
||||||
return self._activation_report
|
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:
|
def _proxy_time_ms(self) -> int:
|
||||||
if not self._activated or self._now_ms_provider is None or self._t0_ms is None:
|
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")
|
raise RuntimeError("proxy not activated — call activate(...) first")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -222,9 +222,9 @@ def _resolve_frame_sink(): # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
def _drive_fc_proxy(schedule_path: Path) -> None:
|
def _drive_fc_proxy(schedule_path: Path) -> None:
|
||||||
raise NotImplementedError(
|
from runner.helpers.fc_proxy_runtime import drive_fc_proxy
|
||||||
"FC-inbound spoof proxy driver is owned by AZ-441 / runner.helpers.fc_proxy_runtime"
|
|
||||||
)
|
drive_fc_proxy(schedule_path)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_frame_period_ms() -> int:
|
def _resolve_frame_period_ms() -> int:
|
||||||
|
|||||||
Reference in New Issue
Block a user