mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:51:12 +00:00
d7e6b0959e
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>
206 lines
5.0 KiB
Python
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)
|