mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:21:13 +00:00
702a0c0ff3
AZ-408 (3pt) — Replace AZ-406 injector scaffolds with concrete generators: - outlier.py: deterministic stride + far-away tile replacement; AC-2 ≥350m offset - blackout_spoof.py: paired video blackout + FC GPS spoof with ≤40ms alignment; AC-4 realistic fix_type/hdop; AC-NEW-8 200-500m inter-spoof deltas - multi_segment.py: ≥3 disjoint windows, ≥30s gaps, ≤25% coverage - fc_proxy.py: timed-splice runtime proxy with pre-activate RuntimeError guard - _common.py: derive_rng + tile-manifest reader + tmpfs helpers - injector_fixtures.py: pytest fixtures wired via runner conftest AZ-410 (3pt) — FT-P-02 cumulative drift between satellite anchors: - anchor_pair_detector.py: AC-1 detection, AC-2/3 pass-fraction, AC-4 monotonicity check, CSV evidence - test_ft_p_02_derkachi_drift.py: scenario gated on upstream helper NotImplementedError (frame_source_replay / fdr_reader / imu_replay) AZ-411 (2pt) — FT-P-03 + FT-P-14 schema + WGS84: - estimate_schema.py: AC-1 schema completeness, AC-2 source-label set containment, AC-3 WGS84 range + int32 1e-7 decode - test_ft_p_03_14_schema_wgs84.py: shared single-image-push scenario Tests: 248 unit tests pass (+91 vs batch 68). Reports: batch_69_report.md, batch_69_review.md (PASS), cumulative_review_batches_67-69_cycle1_report.md (PASS). Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""Behavioural tests for the AZ-408 FC inbound proxy patch.
|
|
|
|
Covers AC-3 (video↔proxy alignment ≤ 40 ms — verified end-to-end via the
|
|
fake clock here; the runtime path observes the same invariant) and the
|
|
proxy's pass-through / spoof-replace semantics.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy, SpoofGpsRecord
|
|
|
|
|
|
class _FakeClock:
|
|
"""Monotonic ms clock that the test advances manually."""
|
|
|
|
def __init__(self, start_ms: int = 0) -> None:
|
|
self.now_ms = start_ms
|
|
|
|
def __call__(self) -> int:
|
|
return self.now_ms
|
|
|
|
def advance(self, ms: int) -> None:
|
|
self.now_ms += ms
|
|
|
|
|
|
def _spoof_records() -> list[SpoofGpsRecord]:
|
|
return [
|
|
SpoofGpsRecord(monotonic_ms=1000 + i * 100, lat_deg=50.0 + i * 0.001,
|
|
lon_deg=36.1, alt_m=300.0, fix_type=3, hdop=1.0)
|
|
for i in range(5)
|
|
]
|
|
|
|
|
|
def test_proxy_passes_through_outside_window() -> None:
|
|
# Arrange — schedule the first blackout 500 ms in the future. The
|
|
# activate() call binds proxy_time(now) = 0; the window opens at
|
|
# window_start_ms = 500 in proxy time. Now (proxy_time = 0) is
|
|
# outside [500, 1000], so the proxy must pass through.
|
|
clock = _FakeClock(start_ms=1000)
|
|
proxy = BlackoutSpoofProxy(window_start_ms=500, window_end_ms=1000,
|
|
spoof_gps=_spoof_records())
|
|
proxy.activate(now_ms_provider=clock, first_blackout_ms=1500)
|
|
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
|
|
|
|
# Act
|
|
out = proxy.process_inbound_message(msg)
|
|
|
|
# Assert
|
|
assert out == msg
|
|
assert "__spoofed__" not in out
|
|
|
|
|
|
def test_proxy_spoofs_inside_window() -> None:
|
|
# Arrange
|
|
clock = _FakeClock(start_ms=0)
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
|
|
spoof_gps=_spoof_records())
|
|
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
|
|
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
|
|
|
|
# Act — clock=0 ⇒ proxy_time(0) = 0 (inside window)
|
|
out = proxy.process_inbound_message(msg)
|
|
|
|
# Assert
|
|
assert out["__spoofed__"] is True
|
|
assert out["lat_deg"] != msg["lat_deg"]
|
|
assert out["fix_type"] == 3
|
|
|
|
|
|
def test_proxy_returns_to_passthrough_after_window() -> None:
|
|
# Arrange
|
|
clock = _FakeClock(start_ms=0)
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
|
|
spoof_gps=_spoof_records())
|
|
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
|
|
|
|
# Act — advance past end of window
|
|
clock.advance(1000)
|
|
msg = {"lat_deg": 50.0, "lon_deg": 36.0, "alt_m": 300.0}
|
|
out = proxy.process_inbound_message(msg)
|
|
|
|
# Assert
|
|
assert out == msg
|
|
|
|
|
|
def test_alignment_err_below_40ms_when_clock_matches_first_blackout() -> None:
|
|
"""AC-3: when the test harness calls activate() at the same ms the
|
|
first blackout frame fires, alignment error is 0."""
|
|
# Arrange
|
|
clock = _FakeClock(start_ms=12_345)
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
|
|
|
|
# Act
|
|
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_345)
|
|
|
|
# Assert
|
|
assert report.alignment_err_ms == 0
|
|
assert report.alignment_err_ms <= 40
|
|
|
|
|
|
def test_alignment_err_within_budget_under_normal_clock_skew() -> None:
|
|
"""Real harness can have a 30 ms skew between video & proxy; still inside AC-3."""
|
|
# Arrange
|
|
clock = _FakeClock(start_ms=12_400)
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
|
|
|
|
# Act — first_blackout_ms is 30 ms earlier than clock (harness skew)
|
|
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_370)
|
|
|
|
# Assert
|
|
assert report.alignment_err_ms == 30
|
|
assert report.alignment_err_ms <= 40
|
|
|
|
|
|
def test_exhausting_spoof_list_repeats_last() -> None:
|
|
"""When the spoofed-GPS list is drained, the FC keeps seeing the last record."""
|
|
# Arrange
|
|
clock = _FakeClock(start_ms=0)
|
|
spoofs = _spoof_records()
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=10_000, spoof_gps=spoofs)
|
|
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
|
|
|
|
# Act — pull 10 frames (more than the 5 in the list)
|
|
outs = [proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0}) for _ in range(10)]
|
|
|
|
# Assert — last 5 outputs all reuse the final spoof record
|
|
last = spoofs[-1]
|
|
for o in outs[-3:]:
|
|
assert o["lat_deg"] == last.lat_deg
|
|
assert o["lon_deg"] == last.lon_deg
|
|
|
|
|
|
def test_from_schedule_file_round_trip(tmp_path: Path) -> None:
|
|
# Arrange
|
|
sched_path = tmp_path / "schedule.json"
|
|
sched_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"window_start_ms": 0,
|
|
"window_end_ms": 200,
|
|
"max_alignment_err_ms": 40.0,
|
|
"blackout_frame_indices": [0, 1, 2],
|
|
"spoof_gps": [
|
|
{"monotonic_ms": 0, "lat_deg": 50.0, "lon_deg": 36.0,
|
|
"alt_m": 300.0, "fix_type": 3, "hdop": 1.0},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
# Act
|
|
proxy = BlackoutSpoofProxy.from_schedule_file(sched_path)
|
|
proxy.activate(now_ms_provider=lambda: 0)
|
|
out = proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0})
|
|
|
|
# Assert
|
|
assert out["__spoofed__"] is True
|
|
assert out["lat_deg"] == 50.0
|
|
|
|
|
|
def test_from_schedule_file_missing_raises(tmp_path: Path) -> None:
|
|
# Arrange / Act / Assert
|
|
with pytest.raises(FileNotFoundError):
|
|
BlackoutSpoofProxy.from_schedule_file(tmp_path / "missing.json")
|
|
|
|
|
|
def test_process_before_activate_raises() -> None:
|
|
# Arrange
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=_spoof_records())
|
|
# Act / Assert
|
|
with pytest.raises(RuntimeError, match="not activated"):
|
|
proxy.process_inbound_message({})
|
|
|
|
|
|
def test_in_window_false_before_activate() -> None:
|
|
# Arrange
|
|
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=[])
|
|
# Act / Assert
|
|
assert proxy.in_window() is False
|