mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 20:21:17 +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>
197 lines
5.7 KiB
Python
197 lines
5.7 KiB
Python
"""Unit tests for the AZ-411 estimate-schema validators (FT-P-03, FT-P-14).
|
|
|
|
Validates AC-1 (schema completeness), AC-2 (source-label set containment),
|
|
AC-3 (WGS84 range), and the int32 1e-7 decoder. The full single-image
|
|
push scenario in ``test_ft_p_03_14_schema_wgs84.py`` is skipped until
|
|
the upstream replay/SITL helpers land — these tests are the AC coverage
|
|
for the logic itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from runner.helpers.estimate_schema import (
|
|
ALLOWED_SOURCE_LABELS,
|
|
LAT_LON_SCALE,
|
|
REQUIRED_FIELDS,
|
|
aggregate_validations,
|
|
decode_lat_lon_int32,
|
|
validate_estimate_schema,
|
|
validate_source_label,
|
|
validate_wgs84_range,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-1: schema completeness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _valid_record(**overrides: object) -> dict:
|
|
"""A baseline record that satisfies all four REQUIRED_FIELDS."""
|
|
return {
|
|
"lat": 50.075,
|
|
"lon": 36.150,
|
|
"cov_semi_major_m": 4.5,
|
|
"last_satellite_anchor_age_ms": 1234,
|
|
**overrides,
|
|
}
|
|
|
|
|
|
def test_valid_record_passes_schema() -> None:
|
|
# Arrange / Act
|
|
result = validate_estimate_schema(_valid_record())
|
|
# Assert
|
|
assert result.ok is True
|
|
assert result.missing_fields == []
|
|
assert result.wrong_typed_fields == []
|
|
|
|
|
|
def test_missing_field_caught() -> None:
|
|
# Arrange
|
|
rec = _valid_record()
|
|
del rec["cov_semi_major_m"]
|
|
# Act
|
|
result = validate_estimate_schema(rec)
|
|
# Assert
|
|
assert not result.ok
|
|
assert "cov_semi_major_m" in result.missing_fields
|
|
|
|
|
|
def test_int_typed_field_rejected_when_wrong_type() -> None:
|
|
# Arrange — last_satellite_anchor_age_ms is supposed to be int, not float
|
|
rec = _valid_record(last_satellite_anchor_age_ms=1.5)
|
|
# Act
|
|
result = validate_estimate_schema(rec)
|
|
# Assert
|
|
assert not result.ok
|
|
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
|
|
|
|
|
|
def test_bool_does_not_silently_satisfy_int() -> None:
|
|
"""Python ``isinstance(True, int)`` is True; we must reject it explicitly."""
|
|
# Arrange
|
|
rec = _valid_record(last_satellite_anchor_age_ms=True)
|
|
# Act
|
|
result = validate_estimate_schema(rec)
|
|
# Assert
|
|
assert not result.ok
|
|
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
|
|
|
|
|
|
def test_required_fields_table_is_what_the_spec_says() -> None:
|
|
"""Guard against accidental drift between the helper and the AZ-411 spec."""
|
|
# Arrange
|
|
names = [n for n, _ in REQUIRED_FIELDS]
|
|
# Assert
|
|
assert names == ["lat", "lon", "cov_semi_major_m", "last_satellite_anchor_age_ms"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-2: source-label set containment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("label", sorted(ALLOWED_SOURCE_LABELS))
|
|
def test_each_allowed_label_passes(label: str) -> None:
|
|
# Arrange / Act
|
|
result = validate_source_label(label)
|
|
# Assert
|
|
assert result.ok
|
|
assert result.observed == label
|
|
|
|
|
|
def test_unknown_label_rejected() -> None:
|
|
# Arrange / Act
|
|
result = validate_source_label("imu_only")
|
|
# Assert
|
|
assert not result.ok
|
|
assert "not in" in (result.reason or "")
|
|
|
|
|
|
def test_non_string_label_rejected() -> None:
|
|
# Arrange / Act
|
|
result = validate_source_label(42)
|
|
# Assert
|
|
assert not result.ok
|
|
assert "expected str" in (result.reason or "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-3: WGS84 range + int32 decoding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_valid_wgs84_inside_range() -> None:
|
|
# Arrange / Act
|
|
result = validate_wgs84_range(50.075, 36.150)
|
|
# Assert
|
|
assert result.ok
|
|
|
|
|
|
def test_lat_above_90_rejected() -> None:
|
|
# Arrange / Act / Assert
|
|
assert not validate_wgs84_range(91.0, 0.0).ok
|
|
|
|
|
|
def test_lon_below_minus_180_rejected() -> None:
|
|
# Arrange / Act / Assert
|
|
assert not validate_wgs84_range(0.0, -181.0).ok
|
|
|
|
|
|
def test_nan_rejected() -> None:
|
|
# Arrange / Act / Assert
|
|
assert not validate_wgs84_range(math.nan, 0.0).ok
|
|
|
|
|
|
def test_decode_lat_lon_int32_round_trip() -> None:
|
|
# Arrange — encode Derkachi-ish coords as int32 1e-7 then decode
|
|
lat_e7 = 500_750_000
|
|
lon_e7 = 361_500_000
|
|
# Act
|
|
lat, lon = decode_lat_lon_int32(lat_e7, lon_e7)
|
|
# Assert
|
|
assert abs(lat - 50.075) < 1e-6
|
|
assert abs(lon - 36.150) < 1e-6
|
|
assert lat == lat_e7 * LAT_LON_SCALE
|
|
|
|
|
|
def test_decode_lat_lon_int32_rejects_out_of_int32_range() -> None:
|
|
# Arrange / Act / Assert
|
|
with pytest.raises(ValueError, match="lat_e7"):
|
|
decode_lat_lon_int32(2 ** 31, 0)
|
|
with pytest.raises(ValueError, match="lon_e7"):
|
|
decode_lat_lon_int32(0, -(2 ** 31) - 1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# aggregate_validations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_aggregate_validations_all_ok() -> None:
|
|
# Arrange
|
|
records = [_valid_record(), _valid_record(lat=49.9, lon=36.0)]
|
|
# Act
|
|
schemas, wgs84s = aggregate_validations(records)
|
|
# Assert
|
|
assert all(s.ok for s in schemas)
|
|
assert all(w.ok for w in wgs84s)
|
|
|
|
|
|
def test_aggregate_validations_surfaces_bad_record() -> None:
|
|
# Arrange — one good, one missing lat
|
|
bad = _valid_record()
|
|
del bad["lat"]
|
|
records = [_valid_record(), bad]
|
|
# Act
|
|
schemas, wgs84s = aggregate_validations(records)
|
|
# Assert
|
|
assert schemas[0].ok
|
|
assert not schemas[1].ok
|
|
# When lat is missing, wgs84 validator emits a missing-field result too.
|
|
assert not wgs84s[1].ok
|