[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:
Oleksandr Bezdieniezhnykh
2026-05-17 09:08:48 +03:00
parent 43fdef1aac
commit 6554d568f1
9 changed files with 667 additions and 5 deletions
@@ -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()
+1
View File
@@ -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",
+12
View File
@@ -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")
+119
View File
@@ -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:
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: