mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:01:12 +00:00
2c31cc094f
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>
555 lines
16 KiB
Python
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"
|