Files
gps-denied-onboard/e2e/_unit_tests/helpers/test_estimate_schema.py
T
Oleksandr Bezdieniezhnykh 702a0c0ff3 [AZ-408] [AZ-410] [AZ-411] Batch 69: synth injectors + FT-P-02/03/14
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>
2026-05-16 17:54:00 +03:00

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