Files
gps-denied-onboard/tests/unit/test_az402_replay_cli.py
T
Oleksandr Bezdieniezhnykh 6be207cef3 [AZ-894] [AZ-896] Add CSV-driven replay adapter + format docs
Replaces the tlog two-clock replay surface with a single-clock path
driven by the Derkachi-schema CSV. --imu is the new required CLI arg;
--tlog stays as a deprecated alias (warned + ignored when --imu set)
until AZ-895 deletes it.

* csv_ground_truth.py parses the 15-column schema, fails fast at
  startup on every documented schema fault (AC-5).
* CsvReplayFcAdapter slots into ReplayInputBundle.fc_adapter alongside
  the tlog sibling; mirrors Invariant-5 outbound wiring; inbound bus is
  intentionally a no-op since the loop reads CSV directly.
* _run_replay_loop branches on imu_csv_path, stamps
  VioOutput.emitted_at_ns from the CSV-derived frame_end_ns (AC-4),
  closing the AZ-848 two-clock surface for the new path.
* AZ-896 ships the operator-facing format spec at
  _docs/02_document/contracts/replay/csv_replay_format.md plus a
  20-row example CSV (AC-3 regression-locked).

Tests: 11 + 12 new unit tests, plus updates to AZ-401 import-boundary
and AZ-402 CLI suites. Full unit suite 2,327 passed / 86 skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 18:40:29 +03:00

562 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")
imu_csv = tmp_path / "data_imu.csv"
# Minimal placeholder — the CLI only validates existence, parsing
# happens later inside the runtime loop.
imu_csv.write_text("placeholder", encoding="utf-8")
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,
"imu": imu_csv,
"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"]),
"--imu": str(files["imu"]),
"--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",
"--imu",
"--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"