Files
gps-denied-onboard/tests/e2e/replay/test_helpers.py
T
Oleksandr Bezdieniezhnykh d7e6b0959e [AZ-404] [AZ-389] [AZ-559] E2E replay test (Derkachi 60s) + AZ-389 cleanup
Batch 63 of /autodev replay slice. Adds the AZ-404 E2E test harness
against the Derkachi fixture and resolves the AZ-389 dependency
phantom (closing AZ-559 Won't Fix).

E2E test (AZ-404)
- tests/e2e/replay/_tlog_synth.py: deterministic CSV->tlog generator
  (the original Derkachi tlog is not in repo; data_imu.csv is its
  export, so we round-trip the CSV through pymavlink). Verified:
  SCALED_IMU2 + ATTITUDE + GPS_RAW_INT + HEARTBEAT round-trip cleanly
  through mavutil.mavlink_connection.
- tests/e2e/replay/_helpers.py: parse_jsonl, l2_horizontal_m
  (haversine), match_percentage, CapturingMavlinkTransport (ready
  for AZ-558 unblock), GroundTruthRow + load_ground_truth_csv.
- tests/e2e/replay/conftest.py: derkachi_replay_inputs (session
  scope), replay_runner (subprocess fixture per AZ-402 CLI),
  operator_pre_flight_setup placeholder.
- tests/e2e/replay/test_derkachi_1min.py: 9 tests covering AC-1..AC-8
  with AC-7 skip-gate self-check + AC-4a mode-agnosticism AST scan
  (passes unconditionally, confirms ADR-011 holding).
- tests/e2e/replay/test_helpers.py: 14 unit tests covering AC-9
  helper L2 correctness + match_percentage + parse_jsonl +
  CapturingMavlinkTransport (all unconditional).
- tests/e2e/replay/README.md: AC matrix, fixture state, runtime
  budget, failure cookbook (AC-10).

AC matrix
- AC-1, AC-2, AC-5, AC-6 implemented and Tier-1 gated on
  RUN_REPLAY_E2E=1.
- AC-3 (<=100m for 80%) xfail until real Topotek KHP20S30
  calibration ships (camera_info.md states intrinsics are unknown).
- AC-4a (mode-agnosticism AST scan) PASSES unconditionally.
- AC-4b (encoder byte-equality) skip until AZ-558 routes C8 bytes
  through MavlinkTransport.
- AC-7 (skip-gate self-check) PASSES unconditionally.
- AC-8 (operator workflow rehearsal) skip until D-PROJ-2
  mock-suite-sat-service implements tile-fetch + index-build
  endpoints.
- AC-9 (helper L2 correctness) 14 PASSES unconditionally.

AZ-389 housekeeping
- AZ-559 closed Won't Fix: investigation against
  c6_tile_cache/_types.py confirmed TileSource.ONBOARD_INGEST +
  TileMetadata.quality_metadata + write_tile's FreshnessRejectionError
  already cover the mid-flight ingest semantic. The "missing API"
  was a spec-vs-impl naming mismatch.
- AZ-389 spec rewritten to consume the existing write_tile API +
  catch FreshnessRejectionError per AC-NEW-3 opportunistic emission.
- _dependencies_table.md reverted: AZ-389 deps -> AZ-303 (was
  AZ-559 in the previous commit on this branch); total 150 / 497
  pts.

Tests
- Full regression: 2099 passed (+14 new e2e/replay), 94 skipped
  (incl. 8 e2e/replay heavy-tier + documented blocker skips), 3
  perf-microbench flakes deselected (test_cli_cold_start_under_2s,
  test_cold_start_under_500ms_p99, test_nfr_perf_sign_microbench;
  all pass in isolation - pre-existing under-load flakes on dev
  macOS).

Reviews
- _docs/03_implementation/reviews/batch_63_review.md: code review
  PASS_WITH_WARNINGS (3 documented spec-gap deferrals: AC-3, AC-4b,
  AC-8).
- _docs/03_implementation/cumulative_review_batches_61-63_cycle1_report.md:
  cumulative review PASS_WITH_WARNINGS. Action items: prioritise
  AZ-558 (closes AZ-401 AC-9 + AZ-404 AC-4b); consider 2pt hygiene
  PBI for Protocol-completeness AST scan to catch the AZ-389 /
  AZ-559 phantom-API pattern at task-prep time.

Architecture invariants observably holding
- ADR-011 (replay-as-configuration): AC-4a's AST scan over
  src/gps_denied_onboard/components/**/*.py finds zero violations -
  components branch on neither config.mode nor any synonym.
- Single composition root (replay protocol Invariant 11): AZ-402
  CLI dispatches to runtime_root.main(config); does not call
  compose_root directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 21:41:39 +03:00

206 lines
5.0 KiB
Python

"""Unit-level tests for the AZ-404 e2e helpers.
Runs unconditionally in the regular regression suite (NOT gated by
``RUN_REPLAY_E2E``) — the helpers are pure / deterministic and test
themselves cheaply. Covers AC-9 (Helper L2 computation correct) and
ancillary helper invariants.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.e2e.replay._helpers import (
CapturingMavlinkTransport,
GroundTruthRow,
l2_horizontal_m,
match_percentage,
parse_jsonl,
)
# ----------------------------------------------------------------------
# AC-9: L2 helper correctness
def test_ac9_l2_zero_at_same_point() -> None:
# Arrange / Act
d = l2_horizontal_m(50.08, 36.11, 50.08, 36.11)
# Assert
assert d == pytest.approx(0.0, abs=1e-6)
def test_ac9_l2_north_one_degree_111km() -> None:
"""One degree of latitude ≈ 111 km on the WGS84 spherical model."""
# Act
d = l2_horizontal_m(50.08, 36.11, 51.08, 36.11)
# Assert
assert d == pytest.approx(111_195.0, rel=0.001)
def test_ac9_l2_known_pair_kharkiv_kyiv() -> None:
"""Hand-checked Derkachi (~Kharkiv) to Kyiv center: 411 km ± 1 km."""
# Arrange
kharkiv_lat, kharkiv_lon = 49.9935, 36.2304
kyiv_lat, kyiv_lon = 50.4501, 30.5234
# Act
d = l2_horizontal_m(kharkiv_lat, kharkiv_lon, kyiv_lat, kyiv_lon)
# Assert — externally known reference distance is 411 km.
assert d == pytest.approx(411_000.0, rel=0.005)
def test_ac9_l2_symmetric() -> None:
# Arrange
a = (49.991, 36.221)
b = (50.080, 36.111)
# Act
d_ab = l2_horizontal_m(*a, *b)
d_ba = l2_horizontal_m(*b, *a)
# Assert
assert d_ab == pytest.approx(d_ba, rel=1e-12)
# ----------------------------------------------------------------------
# match_percentage
def test_match_percentage_all_within_threshold() -> None:
# Arrange
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
emissions = [
{
"emitted_at": 0,
"position_wgs84": {"lat_deg": 50.0, "lon_deg": 36.0, "alt_m": 100.0},
}
]
# Act
pct = match_percentage(emissions, gt, threshold_m=100.0)
# Assert
assert pct == 1.0
def test_match_percentage_none_within_threshold() -> None:
# Arrange
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
emissions = [
{
"emitted_at": 0,
# ~111 km north of the GT row.
"position_wgs84": {"lat_deg": 51.0, "lon_deg": 36.0, "alt_m": 100.0},
}
]
# Act
pct = match_percentage(emissions, gt, threshold_m=100.0)
# Assert
assert pct == 0.0
def test_match_percentage_empty_emissions_zero() -> None:
# Arrange
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
# Act
pct = match_percentage([], gt, threshold_m=100.0)
# Assert
assert pct == 0.0
def test_match_percentage_empty_ground_truth_raises() -> None:
# Act / Assert
with pytest.raises(AssertionError, match="ground_truth must be non-empty"):
match_percentage(
[{"emitted_at": 0, "position_wgs84": {"lat_deg": 50, "lon_deg": 36}}],
[],
threshold_m=100.0,
)
# ----------------------------------------------------------------------
# parse_jsonl
def test_parse_jsonl_round_trip(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text('{"a": 1}\n{"b": 2}\n')
# Act
rows = parse_jsonl(path)
# Assert
assert rows == [{"a": 1}, {"b": 2}]
def test_parse_jsonl_skips_trailing_blank(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text('{"a": 1}\n\n')
# Act
rows = parse_jsonl(path)
# Assert — the trailing blank line is tolerated
assert rows == [{"a": 1}]
def test_parse_jsonl_invalid_line_raises(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text("not json\n")
# Act / Assert
with pytest.raises(AssertionError, match="not valid JSON"):
parse_jsonl(path)
# ----------------------------------------------------------------------
# CapturingMavlinkTransport (ready for AZ-558 unblock)
def test_capturing_transport_records_writes() -> None:
# Arrange
t = CapturingMavlinkTransport()
# Act
t.write(b"abc")
t.write(b"def")
# Assert
assert t.captured_payloads == (b"abc", b"def")
assert t.captured_concat == b"abcdef"
assert t.bytes_written() == 6
def test_capturing_transport_close_then_write_raises() -> None:
# Arrange
t = CapturingMavlinkTransport()
t.close()
# Act / Assert
with pytest.raises(RuntimeError, match="after close"):
t.write(b"x")
def test_capturing_transport_implements_protocol() -> None:
# Arrange
from gps_denied_onboard.components.c8_fc_adapter.interface import MavlinkTransport
# Act
t = CapturingMavlinkTransport()
# Assert — runtime_checkable Protocol acceptance
assert isinstance(t, MavlinkTransport)