"""AZ-402 — `gps-denied-replay` console-script unit tests. Covers AC-1..AC-10 of the AZ-402 task spec. AC-10 (console-script registered) ships as both a static pyproject.toml assertion and a subprocess smoke test gated on the package being installed. Implements ``_docs/02_document/contracts/replay/replay_protocol.md`` v2.0.0 — CLI surface + Invariant 11. """ from __future__ import annotations import json import os import subprocess import sys from collections.abc import Iterator from pathlib import Path from typing import Any from unittest import mock import pytest import numpy as np from gps_denied_onboard.cli import replay as replay_cli from gps_denied_onboard.cli.replay import ( EXIT_GENERIC_FAILURE, EXIT_SUCCESS, EXIT_SYNC_IMPOSSIBLE, ReplayCliError, ) from gps_denied_onboard.config import Config from gps_denied_onboard.replay_input import ReplayInputAdapterError # ---------------------------------------------------------------------- # Fixtures @pytest.fixture def _calib_payload() -> dict[str, Any]: return { "camera_id": "test-cam", "intrinsics_3x3": np.eye(3).tolist(), "distortion": [0.0, 0.0, 0.0, 0.0], "body_to_camera_se3": np.eye(4).tolist(), "acquisition_method": "operator", "metadata": {}, } @pytest.fixture def _required_files(tmp_path: Path, _calib_payload: dict[str, Any]) -> dict[str, Path]: """Create real on-disk files for every required CLI arg.""" video = tmp_path / "video.mp4" video.write_bytes(b"\x00\x00\x00\x18ftypmp42") # placeholder tlog = tmp_path / "flight.tlog" tlog.write_bytes(b"\x00") output = tmp_path / "out.jsonl" calib = tmp_path / "calib.json" calib.write_text(json.dumps(_calib_payload)) config_yaml = tmp_path / "config.yaml" config_yaml.write_text("# minimal — env supplies the rest\n") signing_key = tmp_path / "key.bin" signing_key.write_bytes(b"X" * 32) return { "video": video, "tlog": tlog, "output": output, "camera_calibration": calib, "config": config_yaml, "mavlink_signing_key": signing_key, } @pytest.fixture def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set the env vars `load_config` needs to validate successfully.""" for name, value in ( ("GPS_DENIED_FC_PROFILE", "ardupilot_plane"), ("GPS_DENIED_TIER", "1"), ("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"), ("CAMERA_CALIBRATION_PATH", "/will/be/overridden/by/cli.json"), ("LOG_LEVEL", "INFO"), ("LOG_SINK", "console"), ("INFERENCE_BACKEND", "pytorch_fp16"), ("FDR_PATH", "/var/lib/gps-denied/fdr"), ("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"), ): monkeypatch.setenv(name, value) def _argv(files: dict[str, Path], **overrides: Any) -> list[str]: """Build a CLI argv from the required-files fixture + overrides.""" base = { "--video": str(files["video"]), "--tlog": str(files["tlog"]), "--output": str(files["output"]), "--camera-calibration": str(files["camera_calibration"]), "--config": str(files["config"]), "--mavlink-signing-key": str(files["mavlink_signing_key"]), } if "pace" in overrides: base["--pace"] = overrides["pace"] if "time_offset_ms" in overrides and overrides["time_offset_ms"] is not None: base["--time-offset-ms"] = str(overrides["time_offset_ms"]) argv: list[str] = [] for k, v in base.items(): argv.extend([k, v]) return argv # ---------------------------------------------------------------------- # AC-1: All required args parsed def test_ac1_all_required_args_parsed( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SUCCESS cfg = captured["config"] assert cfg.replay.video_path == str(_required_files["video"]) assert cfg.replay.tlog_path == str(_required_files["tlog"]) assert cfg.replay.output_path == str(_required_files["output"]) assert cfg.runtime.camera_calibration_path == str( _required_files["camera_calibration"] ) # ---------------------------------------------------------------------- # AC-2: --pace default ASAP def test_ac2_pace_default_asap( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SUCCESS assert captured["config"].replay.pace == "asap" # ---------------------------------------------------------------------- # AC-3: --pace realtime def test_ac3_pace_realtime( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main( _argv(_required_files, pace="realtime"), shared_main=fake_main ) # Assert assert rc == EXIT_SUCCESS assert captured["config"].replay.pace == "realtime" # ---------------------------------------------------------------------- # AC-4: --time-offset-ms forwarded (None when absent) def test_ac4_time_offset_forwarded( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main( _argv(_required_files, time_offset_ms=5000), shared_main=fake_main ) # Assert assert rc == EXIT_SUCCESS assert captured["config"].replay.time_offset_ms == 5000 def test_ac4_time_offset_none_when_absent( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SUCCESS assert captured["config"].replay.time_offset_ms is None # ---------------------------------------------------------------------- # AC-5: --mavlink-signing-key required (argparse exit 2) def test_ac5_missing_signing_key_exits_2( _required_files: dict[str, Path], capsys: pytest.CaptureFixture[str], ) -> None: # Arrange — drop the signing-key arg pair from argv argv = _argv(_required_files) idx = argv.index("--mavlink-signing-key") del argv[idx : idx + 2] # Act / Assert — argparse raises SystemExit(2) on missing required with pytest.raises(SystemExit) as excinfo: replay_cli.main(argv, shared_main=lambda _c: 0) assert excinfo.value.code == 2 err = capsys.readouterr().err assert "--mavlink-signing-key" in err def test_ac5_missing_video_exits_2(_required_files: dict[str, Path]) -> None: # Arrange argv = _argv(_required_files) idx = argv.index("--video") del argv[idx : idx + 2] # Act / Assert with pytest.raises(SystemExit) as excinfo: replay_cli.main(argv, shared_main=lambda _c: 0) assert excinfo.value.code == 2 # ---------------------------------------------------------------------- # AC-6: Calibration loader rejects malformed JSON def test_ac6_malformed_calibration_exits_1( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange — corrupt the calib.json _required_files["camera_calibration"].write_text("{ this is not json") # Act rc = replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "camera-calibration JSON malformed" in err # ---------------------------------------------------------------------- # AC-7: Calibration schema validation def test_ac7_missing_intrinsics_key_rejected( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange — write a calib.json missing the intrinsics key _required_files["camera_calibration"].write_text( json.dumps( { "distortion": [0.0, 0.0, 0.0, 0.0], "body_to_camera_se3": np.eye(4).tolist(), } ) ) # Act rc = replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "missing 'intrinsics'" in err def test_ac7_top_level_not_object_rejected( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange — JSON parses but top level is a list _required_files["camera_calibration"].write_text(json.dumps([1, 2, 3])) # Act rc = replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "expected JSON object" in err # ---------------------------------------------------------------------- # AC-8: Mode set to replay def test_ac8_mode_set_to_replay( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SUCCESS assert captured["config"].mode == "replay" # The CLI MUST NOT call compose_root directly (replay protocol # Invariant 11). The shared_main fake here proves the dispatch # boundary: if compose_root were called inside the CLI we would # not reach the fake at all. # ---------------------------------------------------------------------- # AC-9: Exit-code pass-through @pytest.mark.parametrize("rc", [0, 1, 2]) def test_ac9_exit_code_pass_through( _required_files: dict[str, Path], _airborne_env: None, rc: int, ) -> None: # Arrange def fake_main(_config: Config) -> int: return rc # Act actual = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert actual == rc def test_ac9_replay_input_adapter_error_maps_to_2( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: """A `ReplayInputAdapterError` raised by shared_main → exit 2.""" # Arrange def fake_main(_config: Config) -> int: raise ReplayInputAdapterError("auto-sync hard-fail: 42% match") # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SYNC_IMPOSSIBLE err = capsys.readouterr().err assert "replay sync impossible" in err assert "42% match" in err def test_unhandled_exception_exits_1_with_traceback( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange def fake_main(_config: Config) -> int: raise ValueError("boom: contrived crash inside compose_root") # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "Traceback" in err assert "boom: contrived crash" in err # ---------------------------------------------------------------------- # Sanitised banner def test_signing_key_redacted_in_startup_banner( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Act replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) # Assert err = capsys.readouterr().err assert "" in err assert str(_required_files["mavlink_signing_key"]) not in err # ---------------------------------------------------------------------- # AC-10: Console script registered def test_ac10_console_script_registered_in_pyproject() -> None: """Static check: pyproject.toml registers the console-script.""" # Arrange repo_root = Path(__file__).resolve().parents[2] pyproject = repo_root / "pyproject.toml" # Act text = pyproject.read_text(encoding="utf-8") # Assert assert ( 'gps-denied-replay = "gps_denied_onboard.cli.replay:main"' in text ), "console script not registered under [project.scripts]" def test_ac10_console_script_runs_help() -> None: """Subprocess: the `gps-denied-replay` script runs `--help` cleanly. Skipped if the package is not installed (or the script is not on PATH); the static assertion in the previous test suffices in that environment. """ # Arrange import shutil binary = shutil.which("gps-denied-replay") if binary is None: venv_bin = Path(sys.executable).parent / "gps-denied-replay" if not venv_bin.exists(): pytest.skip("gps-denied-replay console script not on PATH or in venv bin") binary = str(venv_bin) # Act result = subprocess.run( [binary, "--help"], capture_output=True, text=True, timeout=15 ) # Assert assert result.returncode == 0, result.stderr assert "gps-denied-replay" in result.stdout # Required-arg surface check for arg in ( "--video", "--tlog", "--output", "--camera-calibration", "--config", "--mavlink-signing-key", ): assert arg in result.stdout, f"{arg} missing from --help output" # ---------------------------------------------------------------------- # File validation def test_missing_video_file_exits_1( _required_files: dict[str, Path], _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange _required_files["video"].unlink() # Act rc = replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "video" in err assert "does not exist" in err def test_signing_key_path_must_be_file_not_dir( _required_files: dict[str, Path], tmp_path: Path, _airborne_env: None, capsys: pytest.CaptureFixture[str], ) -> None: # Arrange — pass a directory where a key file is expected fake_dir = tmp_path / "not_a_key_file" fake_dir.mkdir() argv = _argv(_required_files) idx = argv.index("--mavlink-signing-key") argv[idx + 1] = str(fake_dir) # Act rc = replay_cli.main(argv, shared_main=lambda _c: 0) # Assert assert rc == EXIT_GENERIC_FAILURE err = capsys.readouterr().err assert "is not a file" in err # ---------------------------------------------------------------------- # Signing key plumbing def test_signing_key_propagates_to_dev_static_field( _required_files: dict[str, Path], _airborne_env: None ) -> None: # Arrange captured: dict[str, Config] = {} def fake_main(config: Config) -> int: captured["config"] = config return 0 # Act rc = replay_cli.main(_argv(_required_files), shared_main=fake_main) # Assert assert rc == EXIT_SUCCESS expected_hex = (b"X" * 32).hex() assert captured["config"].fc.dev_static_signing_key == expected_hex assert captured["config"].fc.signing_key_source == "dev_static"