"""Pytest fixtures for the AZ-404 E2E replay tests. The fixtures are import-clean on dev macOS — the heavy work (synthesizing the tlog, invoking the airborne CLI in a subprocess) runs only when ``RUN_REPLAY_E2E=1`` is set in the environment. Without the env var, the test module's collection-time skip marker prevents the fixtures from being requested. """ from __future__ import annotations import json import os import shutil import subprocess import sys from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path from typing import Any import pytest from tests.e2e.replay._helpers import GroundTruthRow, load_ground_truth_csv from tests.e2e.replay._tlog_synth import synthesize_tlog # Derkachi clip range — anchored at the start of the data_imu.csv # (Time=0.0). The fixture clip is deliberately the first 60 s rather # than a mid-flight slice: the take-off region exercises the AZ-405 # IMU-take-off auto-sync detector, and the steady cruise that follows # stresses the satellite-anchor + VIO drift-correction path. The # trim is documented in `tests/e2e/replay/README.md`. _CLIP_START_S: float = 0.0 _CLIP_END_S: float = 60.0 # ---------------------------------------------------------------------- # Path helpers def _repo_root() -> Path: return Path(__file__).resolve().parents[3] def _derkachi_dir() -> Path: return _repo_root() / "_docs" / "00_problem" / "input_data" / "flight_derkachi" def _calibration_path() -> Path: # Placeholder calibration: the real Topotek KHP20S30 intrinsics # are unknown per `_docs/00_problem/input_data/flight_derkachi/ # camera_info.md`. AC-3 is `xfail`ed until a real calibration # ships; AC-1 / AC-2 / AC-5 / AC-6 do not depend on intrinsics # accuracy. return _repo_root() / "tests" / "fixtures" / "calibration" / "adti26.json" # ---------------------------------------------------------------------- # Fixtures @dataclass(frozen=True) class DerkachiReplayInputs: """Bundle of paths the AZ-402 CLI consumes for a Derkachi replay run.""" video_path: Path tlog_path: Path calibration_path: Path config_path: Path signing_key_path: Path output_path: Path ground_truth: list[GroundTruthRow] @pytest.fixture(scope="session") def derkachi_replay_inputs(tmp_path_factory: pytest.TempPathFactory) -> DerkachiReplayInputs: """Materialise Derkachi inputs + a synthesized tlog for the e2e run. Session-scoped so the tlog synthesizer runs once across the whole e2e collection. The tlog is cached at ``tmp_path_factory.mktemp("derkachi") / "synth.tlog"`` so each pytest invocation gets a fresh copy; the synthesizer is fast enough (~1 s for 60 s of data) that disk caching across invocations is unnecessary. """ derkachi = _derkachi_dir() csv_path = derkachi / "data_imu.csv" video_path = derkachi / "flight_derkachi.mp4" if not csv_path.is_file(): pytest.fail( f"Derkachi fixture missing: {csv_path} — see " "_docs/00_problem/input_data/flight_derkachi/README.md" ) if not video_path.is_file(): pytest.fail(f"Derkachi fixture missing: {video_path}") work_dir = tmp_path_factory.mktemp("derkachi") tlog_path = work_dir / "synth.tlog" synthesize_tlog(csv_path, tlog_path) # Empty signing key — the airborne replay path runs the signing # handshake against `NoopMavlinkTransport`, so the key contents do # not affect any wire output. We still need a real file because # the CLI's path-validation gate requires it. signing_key_path = work_dir / "signing_key.bin" signing_key_path.write_bytes(b"\x00" * 32) config_path = work_dir / "config.yaml" config_path.write_text( # Replay-specific overrides; the rest comes from the env vars # the airborne binary's `load_config` honours by default. "mode: replay\n" "replay:\n" " pace: asap\n" " target_fc_dialect: ardupilot_plane\n" ) output_path = work_dir / "estimator_output.jsonl" ground_truth_full = load_ground_truth_csv(csv_path) ground_truth = [ r for r in ground_truth_full if _CLIP_START_S <= r.t_s <= _CLIP_END_S ] return DerkachiReplayInputs( video_path=video_path, tlog_path=tlog_path, calibration_path=_calibration_path(), config_path=config_path, signing_key_path=signing_key_path, output_path=output_path, ground_truth=ground_truth, ) @dataclass(frozen=True) class ReplayRunResult: """Outcome of a single ``gps-denied-replay`` subprocess run.""" returncode: int stdout: str stderr: str output_path: Path wall_clock_s: float @pytest.fixture def replay_runner(derkachi_replay_inputs: DerkachiReplayInputs) -> Any: """Return a callable that invokes the ``gps-denied-replay`` console-script. The callable accepts keyword overrides for ``pace``, ``time_offset_ms``, and ``skip_auto_sync`` (AZ-611); everything else is taken from ``derkachi_replay_inputs``. Output is written to a fresh path per invocation so determinism comparisons (AC-5) get two independent files. Derkachi is a mid-flight fixture (no take-off spike) and the only motion the video detector sees in the first 60 s is camera shake and scenery change — neither tlog nor video can produce a reliable auto-sync signal. The synth tlog and the video share the same ``t=0`` anchor by construction (see ``_tlog_synth.py``), so the correct offset is exactly ``0``. The fixture defaults reflect that — heavy ACs pass ``time_offset_ms=0`` + ``skip_auto_sync=True`` so the run never touches the AC-9 validator that would otherwise reject the fixture's false-positive video motion onset. """ binary = shutil.which("gps-denied-replay") if binary is None: venv_bin = Path(sys.executable).parent / "gps-denied-replay" if venv_bin.exists(): binary = str(venv_bin) if binary is None: pytest.skip( "gps-denied-replay console-script not on PATH; " "install the package in the test venv" ) invocation_count = {"n": 0} def _run( *, pace: str = "asap", time_offset_ms: int | None = 0, skip_auto_sync: bool = True, ) -> ReplayRunResult: import time invocation_count["n"] += 1 out_path = derkachi_replay_inputs.output_path.with_name( f"estimator_output_{invocation_count['n']}.jsonl" ) argv = [ binary, "--video", str(derkachi_replay_inputs.video_path), "--tlog", str(derkachi_replay_inputs.tlog_path), "--output", str(out_path), "--camera-calibration", str(derkachi_replay_inputs.calibration_path), "--config", str(derkachi_replay_inputs.config_path), "--mavlink-signing-key", str(derkachi_replay_inputs.signing_key_path), "--pace", pace, ] if time_offset_ms is not None: argv.extend(["--time-offset-ms", str(time_offset_ms)]) if skip_auto_sync: argv.append("--skip-auto-sync") t0 = time.monotonic() completed = subprocess.run( argv, capture_output=True, text=True, timeout=180, ) wall_s = time.monotonic() - t0 return ReplayRunResult( returncode=completed.returncode, stdout=completed.stdout, stderr=completed.stderr, output_path=out_path, wall_clock_s=wall_s, ) return _run @pytest.fixture def operator_pre_flight_setup(tmp_path: Path) -> Iterator[Path]: """Operator C12 pre-flight rehearsal stub. Per AZ-404's spec this fixture should run the operator's full C10/C11/C12 pre-flight against a ``mock-suite-sat-service`` fixture and yield the populated cache directory. The current ``tests/fixtures/mock-suite-sat-service`` is a bootstrap stub (only ``GET /healthz`` per its README) — the full D-PROJ-2 contract is not implemented. Until that ships, AC-8 (operator workflow rehearsal) is skipped at the test level; this fixture yields a placeholder cache directory so test bodies that request it can fail-fast with a documented reason rather than a surprise ImportError. """ cache_dir = tmp_path / "operator_cache" cache_dir.mkdir() yield cache_dir