"""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