Files
gps-denied-onboard/tests/unit/test_az402_replay_cli.py
T
Oleksandr Bezdieniezhnykh 2c31cc094f [AZ-402] Replay — gps-denied-replay console-script + shared main(config)
Implements the replay-mode CLI dispatcher per ADR-011 (replay-as-
configuration):

- src/gps_denied_onboard/cli/replay.py: argparse with all 6 required
  args (--video, --tlog, --output, --camera-calibration, --config,
  --mavlink-signing-key) plus --pace and --time-offset-ms; path
  validation, calibration JSON schema-validation, config mutation
  (mode='replay' + replay sub-block + signing-key hex on dev_static
  field), dispatch into runtime_root.main(config).
- runtime_root.main() now accepts an optional Config (additive,
  backward-compat). Adds dedicated catch for ReplayInputAdapterError
  mapping to EXIT_FDR_OPEN_FAILURE (2) so the CLI's exit-code matrix
  holds end-to-end (AC-9 + epic AZ-265 AC-8).
- Signing-key contents stored as hex; redacted in startup banner.
- Top-level except logs full traceback via logger.exception + stderr
  print and exits 1.

The CLI does NOT call compose_root directly — it builds a Config and
hands it to the shared airborne main, which calls compose_root, which
branches on config.mode (AZ-401 / replay protocol Invariant 11).

Tests: 22 unit tests covering AC-1..AC-10 + extras (signing-key
redaction, file-not-dir validation, dev_static propagation, unhandled
exception traceback). Full regression: 2085 passed (+22) green; no
new flaky tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 20:04:37 +03:00

555 lines
16 KiB
Python

"""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 "<redacted>" 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"