mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:01:14 +00:00
[AZ-416] [AZ-417] [AZ-419] Test batch 72: FT-P-09 AP/iNav + FT-P-11 cold start
- AZ-416 (FT-P-09-AP): fills mavproxy_tlog_reader.iter_messages with pymavlink body (AZ-406 surface kept); adds ap_contract_evaluator covering AC-1 (signing handshake <=5s), AC-2 (GPS_INPUT >=4.5 Hz), AC-3 (EK3_SRC1_POSXY=3), AC-4 (GPS_RAW_INT health >=80%); scenario forces fc_adapter=ardupilot. - AZ-417 (FT-P-09-iNav): msp_frame_observer covering AC-2 (MSP rate) and AC-3 (fix_type/provider/numSat); scenario forces fc_adapter=inav. - AZ-419 (FT-P-11): cold_start_evaluator covering AC-1 (operator manifest origin), AC-2 (FC EKF fallback), AC-3 (no-origin abort), AC-4 (bounded-delta conflict, ADR-010 Principle #11 amended); scenario parametrized on origin_source plus dedicated no-origin abort scenario. - All scenarios skip-gated on upstream frame_source_replay / imu_replay / fdr_reader / sitl_observer extensions. - +67 unit tests; full e2e unit suite: 460 passed. - K=3 cumulative review fired: PASS for batches 70-72. See _docs/03_implementation/batch_72_report.md, _docs/03_implementation/reviews/batch_72_review.md, _docs/03_implementation/cumulative_review_batches_70-72_cycle1_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""Unit tests for ``runner.helpers.ap_contract_evaluator`` (FT-P-09-AP / AZ-416).
|
||||
|
||||
Covers:
|
||||
|
||||
* AC-1 ``observe_signing_handshake``: signed-message detection,
|
||||
``BAD_SIGNATURE`` STATUSTEXT counting, ≤5 s budget.
|
||||
* AC-2 ``compute_gps_input_rate``: ≥4.5 Hz for 5 Hz target.
|
||||
* AC-3 ``validate_ek3_src1_posxy``: only ``3`` passes.
|
||||
* AC-4 ``evaluate_gps_raw_int_health``: ≥80 % healthy fraction
|
||||
(fix_type ≥3 AND eph ≤200).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from runner.helpers.ap_contract_evaluator import (
|
||||
EK3_SRC1_POSXY_REQUIRED,
|
||||
GPS_INPUT_MIN_RATE_HZ,
|
||||
GPS_INPUT_TARGET_RATE_HZ,
|
||||
GPS_RAW_INT_HEALTHY_FRACTION_REQUIRED,
|
||||
GPS_RAW_INT_MAX_EPH,
|
||||
GPS_RAW_INT_MIN_FIX_TYPE,
|
||||
HANDSHAKE_BUDGET_S,
|
||||
compute_gps_input_rate,
|
||||
evaluate_gps_raw_int_health,
|
||||
observe_signing_handshake,
|
||||
validate_ek3_src1_posxy,
|
||||
)
|
||||
from runner.helpers.mavproxy_tlog_reader import TlogMessage
|
||||
|
||||
|
||||
def _msg(ts_us: int, msg_type: str, *, signed: bool = False, **fields: object) -> TlogMessage:
|
||||
return TlogMessage(timestamp_us=ts_us, msg_type=msg_type, signed=signed, fields=fields)
|
||||
|
||||
|
||||
def test_constants_match_spec() -> None:
|
||||
"""The AC-1/2/3/4 thresholds must match the spec text."""
|
||||
# Assert
|
||||
assert HANDSHAKE_BUDGET_S == 5.0
|
||||
assert GPS_INPUT_TARGET_RATE_HZ == 5.0
|
||||
assert GPS_INPUT_MIN_RATE_HZ == 4.5
|
||||
assert GPS_RAW_INT_MIN_FIX_TYPE == 3
|
||||
assert GPS_RAW_INT_MAX_EPH == 200
|
||||
assert GPS_RAW_INT_HEALTHY_FRACTION_REQUIRED == 0.80
|
||||
assert EK3_SRC1_POSXY_REQUIRED == 3
|
||||
|
||||
|
||||
def test_handshake_passes_when_first_signed_within_window() -> None:
|
||||
"""A signed message at +1s passes the 5s budget."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(0, "HEARTBEAT", signed=False),
|
||||
_msg(500_000, "SETUP_SIGNING", signed=False),
|
||||
_msg(1_000_000, "HEARTBEAT", signed=True),
|
||||
]
|
||||
|
||||
# Act
|
||||
report = observe_signing_handshake(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.first_signed_us == 1_000_000
|
||||
assert report.lag_s == pytest.approx(1.0)
|
||||
assert report.setup_signing_seen is True
|
||||
assert report.bad_signature_count == 0
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_handshake_fails_when_no_signed_within_window() -> None:
|
||||
"""No signed message within 5s → AC-1 fail."""
|
||||
# Arrange — only unsigned heartbeats.
|
||||
msgs = [
|
||||
_msg(i * 100_000, "HEARTBEAT", signed=False)
|
||||
for i in range(60) # 6 seconds of 10Hz heartbeats
|
||||
]
|
||||
|
||||
# Act
|
||||
report = observe_signing_handshake(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.first_signed_us is None
|
||||
assert report.lag_s is None
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_handshake_fails_when_signed_arrives_after_budget() -> None:
|
||||
"""Signed message at +6s exceeds the 5s budget → AC-1 fail."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(0, "HEARTBEAT"),
|
||||
_msg(6_000_000, "HEARTBEAT", signed=True),
|
||||
]
|
||||
|
||||
# Act
|
||||
report = observe_signing_handshake(msgs)
|
||||
|
||||
# Assert — the signed message is outside the window, so the iterator
|
||||
# stops before recording it. lag_s stays None.
|
||||
assert report.first_signed_us is None
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_handshake_fails_on_bad_signature_statustext() -> None:
|
||||
"""STATUSTEXT containing BAD_SIGNATURE during the window → AC-1 fail."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(0, "HEARTBEAT"),
|
||||
_msg(500_000, "STATUSTEXT", text="MAVLink2 BAD_SIGNATURE from system 1"),
|
||||
_msg(1_000_000, "HEARTBEAT", signed=True),
|
||||
]
|
||||
|
||||
# Act
|
||||
report = observe_signing_handshake(msgs)
|
||||
|
||||
# Assert — got a signed message but ALSO a BAD_SIGNATURE in the window.
|
||||
assert report.first_signed_us == 1_000_000
|
||||
assert report.bad_signature_count == 1
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_handshake_empty_stream_does_not_pass() -> None:
|
||||
"""No messages → no window → does not pass."""
|
||||
# Act
|
||||
report = observe_signing_handshake([])
|
||||
|
||||
# Assert
|
||||
assert report.window_start_us == 0
|
||||
assert report.first_signed_us is None
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_handshake_rejects_invalid_window() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="handshake_window_us"):
|
||||
observe_signing_handshake([], handshake_window_us=0)
|
||||
|
||||
|
||||
def test_gps_input_rate_at_5hz_for_60s_passes() -> None:
|
||||
"""60s @ 5Hz = 301 frames (incl. t=0 and t=60s) → 5.0 Hz observed."""
|
||||
# Arrange
|
||||
msgs = [_msg(i * 200_000, "GPS_INPUT") for i in range(301)]
|
||||
|
||||
# Act
|
||||
report = compute_gps_input_rate(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 301
|
||||
assert report.observed_rate_hz == pytest.approx(5.0, abs=0.01)
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_gps_input_rate_at_boundary_passes() -> None:
|
||||
"""4.5 Hz exactly → AC-2 boundary pass."""
|
||||
# Arrange — 10s @ 4.5Hz = 46 frames (start + 45 intervals).
|
||||
period_us = int(round(1_000_000 / 4.5))
|
||||
msgs = [_msg(i * period_us, "GPS_INPUT") for i in range(46)]
|
||||
|
||||
# Act
|
||||
report = compute_gps_input_rate(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.observed_rate_hz == pytest.approx(4.5, abs=0.05)
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_gps_input_rate_below_minimum_fails() -> None:
|
||||
"""3 Hz observed → AC-2 fail."""
|
||||
# Arrange — 10s @ 3Hz.
|
||||
msgs = [_msg(i * 333_333, "GPS_INPUT") for i in range(31)]
|
||||
|
||||
# Act
|
||||
report = compute_gps_input_rate(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.observed_rate_hz == pytest.approx(3.0, abs=0.05)
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_gps_input_rate_ignores_other_messages() -> None:
|
||||
"""Only GPS_INPUT frames count; HEARTBEAT/GPS_RAW_INT are noise."""
|
||||
# Arrange — 5 GPS_INPUT + many HEARTBEATs.
|
||||
msgs = [_msg(i * 200_000, "GPS_INPUT") for i in range(5)]
|
||||
msgs += [_msg(i * 100_000, "HEARTBEAT") for i in range(50)]
|
||||
|
||||
# Act
|
||||
report = compute_gps_input_rate(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 5
|
||||
|
||||
|
||||
def test_gps_input_rate_empty_stream_does_not_pass() -> None:
|
||||
# Act
|
||||
report = compute_gps_input_rate([])
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 0
|
||||
assert report.window_us == 0
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_gps_input_rate_rejects_negative_minimum() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="min_required_hz"):
|
||||
compute_gps_input_rate([], min_required_hz=-0.1)
|
||||
|
||||
|
||||
def test_validate_ek3_src1_posxy_passes_at_3() -> None:
|
||||
"""Only the value 3 satisfies AC-3."""
|
||||
# Assert
|
||||
assert validate_ek3_src1_posxy(3) is True
|
||||
assert validate_ek3_src1_posxy(0) is False
|
||||
assert validate_ek3_src1_posxy(1) is False
|
||||
assert validate_ek3_src1_posxy(2) is False
|
||||
assert validate_ek3_src1_posxy(4) is False
|
||||
|
||||
|
||||
def test_gps_raw_int_health_all_healthy_passes() -> None:
|
||||
"""All 100 samples healthy → fraction 1.0 → AC-4 pass."""
|
||||
# Arrange
|
||||
msgs = [_msg(i, "GPS_RAW_INT", fix_type=3, eph=150) for i in range(100)]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.total_samples == 100
|
||||
assert report.healthy_samples == 100
|
||||
assert report.healthy_fraction == 1.0
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_gps_raw_int_health_at_80_pct_boundary_passes() -> None:
|
||||
"""80/100 healthy → boundary inclusive → AC-4 pass."""
|
||||
# Arrange — 80 healthy, 20 with fix_type=2.
|
||||
msgs = [
|
||||
_msg(i, "GPS_RAW_INT", fix_type=3 if i < 80 else 2, eph=150)
|
||||
for i in range(100)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.healthy_fraction == 0.80
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_gps_raw_int_health_below_80_pct_fails() -> None:
|
||||
"""79/100 healthy → AC-4 fail."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(i, "GPS_RAW_INT", fix_type=3 if i < 79 else 2, eph=150)
|
||||
for i in range(100)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.healthy_fraction == pytest.approx(0.79)
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_gps_raw_int_health_eph_threshold_strict() -> None:
|
||||
"""eph=200 is healthy (≤200); eph=201 is not."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(0, "GPS_RAW_INT", fix_type=3, eph=200),
|
||||
_msg(1, "GPS_RAW_INT", fix_type=3, eph=201),
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.total_samples == 2
|
||||
assert report.healthy_samples == 1
|
||||
|
||||
|
||||
def test_gps_raw_int_health_missing_fields_skipped_not_healthy() -> None:
|
||||
"""A GPS_RAW_INT with missing fix_type still increments total but not healthy."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(0, "GPS_RAW_INT", fix_type=3, eph=150),
|
||||
_msg(1, "GPS_RAW_INT"),
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.total_samples == 2
|
||||
assert report.healthy_samples == 1
|
||||
|
||||
|
||||
def test_gps_raw_int_health_ignores_other_message_types() -> None:
|
||||
"""Only GPS_RAW_INT contributes to the total."""
|
||||
# Arrange
|
||||
msgs = [
|
||||
_msg(i, "HEARTBEAT") for i in range(50)
|
||||
] + [
|
||||
_msg(i, "GPS_RAW_INT", fix_type=3, eph=150) for i in range(10)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health(msgs)
|
||||
|
||||
# Assert
|
||||
assert report.total_samples == 10
|
||||
|
||||
|
||||
def test_gps_raw_int_health_empty_stream_does_not_pass() -> None:
|
||||
# Act
|
||||
report = evaluate_gps_raw_int_health([])
|
||||
|
||||
# Assert
|
||||
assert report.total_samples == 0
|
||||
assert report.healthy_fraction == 0.0
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_gps_raw_int_health_rejects_invalid_fraction() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="fraction_required"):
|
||||
evaluate_gps_raw_int_health([], fraction_required=1.5)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Unit tests for ``runner.helpers.cold_start_evaluator`` (FT-P-11 / AZ-419).
|
||||
|
||||
Covers all three FT-P-11 origin_source paths (AC-1 operator manifest,
|
||||
AC-2 fc_ekf, AC-3 no-origin, AC-4 bounded-delta conflict) plus the
|
||||
Manifest read/write + cold-boot fixture parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from runner.helpers.cold_start_evaluator import (
|
||||
ACCURACY_BUDGET_M,
|
||||
BOUNDED_DELTA_TRIGGER_M,
|
||||
FDR_RECORD_BOUNDED_DELTA_REJECT,
|
||||
FDR_RECORD_ORIGIN_SET,
|
||||
FDR_RECORD_ORIGIN_UNAVAILABLE,
|
||||
FORBIDDEN_FIRST_LABEL_BOUNDED_DELTA,
|
||||
ColdBootSnapshot,
|
||||
FdrAuditRecord,
|
||||
LatLonAlt,
|
||||
OutboundEstimate,
|
||||
bounded_delta_distance_m,
|
||||
evaluate_first_estimate,
|
||||
evaluate_no_origin_path,
|
||||
read_cold_boot_fixture,
|
||||
read_manifest,
|
||||
write_manifest,
|
||||
)
|
||||
from runner.helpers.geo import offset
|
||||
|
||||
|
||||
def test_constants_match_spec() -> None:
|
||||
"""The AC-1..AC-4 budgets must match the spec text."""
|
||||
# Assert
|
||||
assert ACCURACY_BUDGET_M == 50.0
|
||||
assert BOUNDED_DELTA_TRIGGER_M == 200.0
|
||||
assert FORBIDDEN_FIRST_LABEL_BOUNDED_DELTA == "satellite_anchored"
|
||||
assert FDR_RECORD_ORIGIN_SET == "c5.cold_start_origin.set"
|
||||
assert FDR_RECORD_ORIGIN_UNAVAILABLE == "c5.cold_start_origin.unavailable"
|
||||
assert FDR_RECORD_BOUNDED_DELTA_REJECT == "c5.gps_bounded_delta.reject"
|
||||
|
||||
|
||||
def test_write_and_read_manifest_round_trip(tmp_path: Path) -> None:
|
||||
"""write_manifest produces JSON read_manifest can parse."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
path = tmp_path / "manifest.json"
|
||||
|
||||
# Act
|
||||
write_manifest(path, origin)
|
||||
parsed = read_manifest(path)
|
||||
|
||||
# Assert
|
||||
assert parsed.takeoff_origin == origin
|
||||
|
||||
|
||||
def test_write_manifest_without_origin_yields_none(tmp_path: Path) -> None:
|
||||
"""None origin → manifest has empty `flight` block."""
|
||||
# Arrange
|
||||
path = tmp_path / "manifest.json"
|
||||
|
||||
# Act
|
||||
write_manifest(path, None)
|
||||
parsed = read_manifest(path)
|
||||
|
||||
# Assert
|
||||
assert parsed.takeoff_origin is None
|
||||
|
||||
|
||||
def test_read_manifest_missing_file_raises(tmp_path: Path) -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(FileNotFoundError, match="manifest not found"):
|
||||
read_manifest(tmp_path / "absent.json")
|
||||
|
||||
|
||||
def test_read_cold_boot_fixture_parses_int_units(tmp_path: Path) -> None:
|
||||
"""lat_e7/lon_e7/alt_mm are converted to decimal degrees + meters."""
|
||||
# Arrange
|
||||
path = tmp_path / "cb.json"
|
||||
payload = {
|
||||
"_schema": "cold-boot-fixture/v1",
|
||||
"global_position_int": {
|
||||
"lat_e7": 500750000,
|
||||
"lon_e7": 361500000,
|
||||
"alt_mm": 100000,
|
||||
},
|
||||
}
|
||||
path.write_text(json.dumps(payload))
|
||||
|
||||
# Act
|
||||
snap = read_cold_boot_fixture(path)
|
||||
|
||||
# Assert
|
||||
assert snap == ColdBootSnapshot(
|
||||
lat_deg=50.0750, lon_deg=36.1500, alt_m=100.0, schema="cold-boot-fixture/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_read_cold_boot_fixture_missing_file_raises(tmp_path: Path) -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(FileNotFoundError, match="cold-boot fixture not found"):
|
||||
read_cold_boot_fixture(tmp_path / "absent.json")
|
||||
|
||||
|
||||
def test_evaluate_operator_manifest_passes_at_origin() -> None:
|
||||
"""AC-1: estimate exactly at origin → distance 0, passes."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000, lat_deg=50.0, lon_deg=36.2, source_label="visual_propagated"
|
||||
)
|
||||
fdr = [
|
||||
FdrAuditRecord(
|
||||
monotonic_ms=500,
|
||||
record_type=FDR_RECORD_ORIGIN_SET,
|
||||
payload={"source": "manifest"},
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="operator_manifest",
|
||||
expected_origin=origin,
|
||||
first_estimate=estimate,
|
||||
fdr_records=fdr,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.distance_m == pytest.approx(0.0, abs=1e-6)
|
||||
assert report.passes_distance is True
|
||||
assert report.fdr_origin_set_seen is True
|
||||
assert report.fdr_origin_set_source == "manifest"
|
||||
|
||||
|
||||
def test_evaluate_operator_manifest_passes_just_inside_budget() -> None:
|
||||
"""AC-1: estimate 49 m from origin → inside the 50 m budget → pass."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
lat, lon = offset(origin.lat_deg, origin.lon_deg, bearing_deg=90.0, distance_m=49.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000, lat_deg=lat, lon_deg=lon, source_label="visual_propagated"
|
||||
)
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="operator_manifest",
|
||||
expected_origin=origin,
|
||||
first_estimate=estimate,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.distance_m == pytest.approx(49.0, abs=0.5)
|
||||
assert report.passes_distance is True
|
||||
|
||||
|
||||
def test_evaluate_operator_manifest_fails_just_outside_budget() -> None:
|
||||
"""AC-1: estimate 51 m from origin → outside the 50 m budget → fail."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
lat, lon = offset(origin.lat_deg, origin.lon_deg, bearing_deg=90.0, distance_m=51.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000, lat_deg=lat, lon_deg=lon, source_label="visual_propagated"
|
||||
)
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="operator_manifest",
|
||||
expected_origin=origin,
|
||||
first_estimate=estimate,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.distance_m == pytest.approx(51.0, abs=0.5)
|
||||
assert report.passes_distance is False
|
||||
|
||||
|
||||
def test_evaluate_operator_manifest_fails_outside_budget() -> None:
|
||||
"""AC-1: estimate 100 m off → distance check fails."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
lat, lon = offset(origin.lat_deg, origin.lon_deg, bearing_deg=90.0, distance_m=100.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000, lat_deg=lat, lon_deg=lon, source_label="visual_propagated"
|
||||
)
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="operator_manifest",
|
||||
expected_origin=origin,
|
||||
first_estimate=estimate,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.distance_m == pytest.approx(100.0, abs=0.5)
|
||||
assert report.passes_distance is False
|
||||
|
||||
|
||||
def test_evaluate_fc_ekf_passes() -> None:
|
||||
"""AC-2: estimate near FC EKF snapshot → AC-2 pass."""
|
||||
# Arrange
|
||||
snapshot_origin = LatLonAlt(lat_deg=50.075, lon_deg=36.15, alt_m=100.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=2000,
|
||||
lat_deg=snapshot_origin.lat_deg,
|
||||
lon_deg=snapshot_origin.lon_deg,
|
||||
source_label="visual_propagated",
|
||||
)
|
||||
fdr = [
|
||||
FdrAuditRecord(
|
||||
monotonic_ms=1500,
|
||||
record_type=FDR_RECORD_ORIGIN_SET,
|
||||
payload={"source": "fc_ekf"},
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="fc_ekf",
|
||||
expected_origin=snapshot_origin,
|
||||
first_estimate=estimate,
|
||||
fdr_records=fdr,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.passes_distance is True
|
||||
assert report.fdr_origin_set_source == "fc_ekf"
|
||||
|
||||
|
||||
def test_evaluate_bounded_delta_conflict_operator_wins() -> None:
|
||||
"""AC-4: estimate near A (operator); source_label != satellite_anchored."""
|
||||
# Arrange
|
||||
a = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
b_lat, b_lon = offset(a.lat_deg, a.lon_deg, bearing_deg=90.0, distance_m=300.0)
|
||||
b = LatLonAlt(lat_deg=b_lat, lon_deg=b_lon, alt_m=200.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000,
|
||||
lat_deg=a.lat_deg,
|
||||
lon_deg=a.lon_deg,
|
||||
source_label="visual_propagated",
|
||||
)
|
||||
fdr = [
|
||||
FdrAuditRecord(
|
||||
monotonic_ms=500,
|
||||
record_type=FDR_RECORD_BOUNDED_DELTA_REJECT,
|
||||
payload={
|
||||
"a": {"lat_deg": a.lat_deg, "lon_deg": a.lon_deg, "alt_m": a.alt_m},
|
||||
"b": {"lat_deg": b.lat_deg, "lon_deg": b.lon_deg, "alt_m": b.alt_m},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="bounded_delta_conflict",
|
||||
expected_origin=a,
|
||||
first_estimate=estimate,
|
||||
fdr_records=fdr,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.passes_distance is True
|
||||
assert report.source_label_ok is True
|
||||
assert report.fdr_bounded_delta_seen is True
|
||||
assert report.fdr_bounded_delta_a == a
|
||||
assert report.fdr_bounded_delta_b is not None
|
||||
assert abs(report.fdr_bounded_delta_b.lat_deg - b.lat_deg) < 1e-9
|
||||
assert bounded_delta_distance_m(a, b) > BOUNDED_DELTA_TRIGGER_M
|
||||
|
||||
|
||||
def test_evaluate_bounded_delta_fails_when_label_is_satellite_anchored() -> None:
|
||||
"""AC-4: source_label = satellite_anchored is FORBIDDEN."""
|
||||
# Arrange
|
||||
a = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=1000,
|
||||
lat_deg=a.lat_deg,
|
||||
lon_deg=a.lon_deg,
|
||||
source_label="satellite_anchored",
|
||||
)
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="bounded_delta_conflict",
|
||||
expected_origin=a,
|
||||
first_estimate=estimate,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.source_label_ok is False
|
||||
|
||||
|
||||
def test_evaluate_first_estimate_rejects_unknown_origin_source() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="unknown origin_source"):
|
||||
evaluate_first_estimate(
|
||||
origin_source="garbage",
|
||||
expected_origin=None,
|
||||
first_estimate=None,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
|
||||
def test_evaluate_first_estimate_handles_no_estimate() -> None:
|
||||
"""If first_estimate is None, distance is None, distance check fails."""
|
||||
# Arrange
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
|
||||
# Act
|
||||
report = evaluate_first_estimate(
|
||||
origin_source="operator_manifest",
|
||||
expected_origin=origin,
|
||||
first_estimate=None,
|
||||
fdr_records=[],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert report.distance_m is None
|
||||
assert report.passes_distance is False
|
||||
assert report.source_label_ok is False
|
||||
|
||||
|
||||
def test_evaluate_no_origin_passes_when_silent_and_fdr_records_abort() -> None:
|
||||
"""AC-3: no estimate produced AND FDR has origin_unavailable → pass."""
|
||||
# Arrange
|
||||
fdr = [
|
||||
FdrAuditRecord(
|
||||
monotonic_ms=15_000,
|
||||
record_type=FDR_RECORD_ORIGIN_UNAVAILABLE,
|
||||
payload={"reason": "no_manifest_no_gps"},
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
report = evaluate_no_origin_path(first_estimate=None, fdr_records=fdr)
|
||||
|
||||
# Assert
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_evaluate_no_origin_fails_when_sut_emits_anything() -> None:
|
||||
"""AC-3: any outbound estimate within the budget is a failure."""
|
||||
# Arrange
|
||||
estimate = OutboundEstimate(
|
||||
monotonic_ms=10_000, lat_deg=0.0, lon_deg=0.0, source_label="dead_reckoned"
|
||||
)
|
||||
|
||||
# Act
|
||||
report = evaluate_no_origin_path(first_estimate=estimate, fdr_records=[])
|
||||
|
||||
# Assert
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_evaluate_no_origin_fails_when_fdr_missing_unavailable_signal() -> None:
|
||||
"""AC-3 also requires the FDR audit record — silence alone is not enough."""
|
||||
# Act
|
||||
report = evaluate_no_origin_path(first_estimate=None, fdr_records=[])
|
||||
|
||||
# Assert
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_bounded_delta_distance_m_exceeds_trigger() -> None:
|
||||
"""200 m offset → exactly at trigger; 250 m → over."""
|
||||
# Arrange
|
||||
a = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||
b1_lat, b1_lon = offset(a.lat_deg, a.lon_deg, bearing_deg=0.0, distance_m=200.0)
|
||||
b1 = LatLonAlt(lat_deg=b1_lat, lon_deg=b1_lon, alt_m=0.0)
|
||||
b2_lat, b2_lon = offset(a.lat_deg, a.lon_deg, bearing_deg=0.0, distance_m=250.0)
|
||||
b2 = LatLonAlt(lat_deg=b2_lat, lon_deg=b2_lon, alt_m=0.0)
|
||||
|
||||
# Assert
|
||||
assert bounded_delta_distance_m(a, b1) == pytest.approx(200.0, abs=1.0)
|
||||
assert bounded_delta_distance_m(a, b2) > BOUNDED_DELTA_TRIGGER_M
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Unit tests for ``runner.helpers.mavproxy_tlog_reader.iter_messages``.
|
||||
|
||||
AZ-416 fills in the pymavlink-backed body; AZ-406 committed the public
|
||||
surface. These tests synthesise a tiny tlog on the fly so the parser
|
||||
can be exercised without needing a captured `.tlog` artifact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pymavlink.dialects.v20 import ardupilotmega as mavlink
|
||||
|
||||
from runner.helpers.mavproxy_tlog_reader import (
|
||||
TlogMessage,
|
||||
count_by_type,
|
||||
iter_messages,
|
||||
)
|
||||
|
||||
_SRC_SYSTEM = 1
|
||||
_SRC_COMPONENT = mavlink.MAV_COMP_ID_AUTOPILOT1
|
||||
_BASE_TS_US = 1_700_000_000_000_000
|
||||
|
||||
|
||||
def _write_tlog(tlog_path: Path, records: list[tuple[int, bytes]]) -> Path:
|
||||
"""Write a synthetic tlog: ``[8B big-endian ts_us][raw frame]`` per record."""
|
||||
with tlog_path.open("wb") as fh:
|
||||
for ts_us, payload in records:
|
||||
fh.write(struct.pack(">Q", ts_us))
|
||||
fh.write(payload)
|
||||
return tlog_path
|
||||
|
||||
|
||||
def _make_mav() -> mavlink.MAVLink:
|
||||
return mavlink.MAVLink(
|
||||
file=None,
|
||||
srcSystem=_SRC_SYSTEM,
|
||||
srcComponent=_SRC_COMPONENT,
|
||||
)
|
||||
|
||||
|
||||
def _heartbeat(mav: mavlink.MAVLink) -> bytes:
|
||||
return mav.heartbeat_encode(
|
||||
type=mavlink.MAV_TYPE_FIXED_WING,
|
||||
autopilot=mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||
base_mode=mavlink.MAV_MODE_FLAG_AUTO_ENABLED,
|
||||
custom_mode=10,
|
||||
system_status=mavlink.MAV_STATE_ACTIVE,
|
||||
).pack(mav)
|
||||
|
||||
|
||||
def _gps_raw_int(mav: mavlink.MAVLink, *, fix_type: int = 3, eph: int = 100) -> bytes:
|
||||
return mav.gps_raw_int_encode(
|
||||
time_usec=_BASE_TS_US,
|
||||
fix_type=fix_type,
|
||||
lat=487750000,
|
||||
lon=375940000,
|
||||
alt=280000,
|
||||
eph=eph,
|
||||
epv=200,
|
||||
vel=12000,
|
||||
cog=18000,
|
||||
satellites_visible=12,
|
||||
).pack(mav)
|
||||
|
||||
|
||||
def _gps_input(mav: mavlink.MAVLink) -> bytes:
|
||||
return mav.gps_input_encode(
|
||||
time_usec=_BASE_TS_US,
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=3,
|
||||
lat=487750000,
|
||||
lon=375940000,
|
||||
alt=280.0,
|
||||
hdop=1.0,
|
||||
vdop=2.0,
|
||||
vn=10.0,
|
||||
ve=5.0,
|
||||
vd=0.5,
|
||||
speed_accuracy=0.3,
|
||||
horiz_accuracy=1.0,
|
||||
vert_accuracy=2.0,
|
||||
satellites_visible=12,
|
||||
).pack(mav)
|
||||
|
||||
|
||||
def test_iter_messages_raises_on_missing_file(tmp_path: Path) -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(FileNotFoundError, match="tlog not found"):
|
||||
list(iter_messages(tmp_path / "absent.tlog"))
|
||||
|
||||
|
||||
def test_iter_messages_yields_message_type_and_fields(tmp_path: Path) -> None:
|
||||
"""A single heartbeat round-trips through iter_messages."""
|
||||
# Arrange
|
||||
mav = _make_mav()
|
||||
tlog = _write_tlog(tmp_path / "single.tlog", [(_BASE_TS_US, _heartbeat(mav))])
|
||||
|
||||
# Act
|
||||
msgs = list(iter_messages(tlog))
|
||||
|
||||
# Assert
|
||||
assert len(msgs) == 1
|
||||
m = msgs[0]
|
||||
assert isinstance(m, TlogMessage)
|
||||
assert m.msg_type == "HEARTBEAT"
|
||||
assert m.fields["autopilot"] == mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA
|
||||
assert "mavpackettype" not in m.fields # excluded by the impl
|
||||
|
||||
|
||||
def test_iter_messages_preserves_order(tmp_path: Path) -> None:
|
||||
"""Multiple records are yielded oldest-first."""
|
||||
# Arrange
|
||||
mav = _make_mav()
|
||||
tlog = _write_tlog(
|
||||
tmp_path / "ordered.tlog",
|
||||
[
|
||||
(_BASE_TS_US + 0, _heartbeat(mav)),
|
||||
(_BASE_TS_US + 1_000_000, _gps_raw_int(mav)),
|
||||
(_BASE_TS_US + 2_000_000, _gps_input(mav)),
|
||||
],
|
||||
)
|
||||
|
||||
# Act
|
||||
types = [m.msg_type for m in iter_messages(tlog)]
|
||||
|
||||
# Assert
|
||||
assert types == ["HEARTBEAT", "GPS_RAW_INT", "GPS_INPUT"]
|
||||
|
||||
|
||||
def test_iter_messages_timestamp_in_microseconds(tmp_path: Path) -> None:
|
||||
"""``msg._timestamp`` is seconds; we expose microseconds."""
|
||||
# Arrange
|
||||
mav = _make_mav()
|
||||
tlog = _write_tlog(tmp_path / "ts.tlog", [(_BASE_TS_US + 5_000_000, _heartbeat(mav))])
|
||||
|
||||
# Act
|
||||
msg = next(iter_messages(tlog))
|
||||
|
||||
# Assert — pymavlink rounds to its frame timestamp; tolerate ±1ms slop.
|
||||
assert abs(msg.timestamp_us - (_BASE_TS_US + 5_000_000)) <= 1_000
|
||||
|
||||
|
||||
def test_iter_messages_signed_flag_default_false(tmp_path: Path) -> None:
|
||||
"""Plain pymavlink-encoded frame is NOT signed → signed=False."""
|
||||
# Arrange
|
||||
mav = _make_mav()
|
||||
tlog = _write_tlog(tmp_path / "u.tlog", [(_BASE_TS_US, _heartbeat(mav))])
|
||||
|
||||
# Act
|
||||
msg = next(iter_messages(tlog))
|
||||
|
||||
# Assert
|
||||
assert msg.signed is False
|
||||
|
||||
|
||||
def test_count_by_type_tallies_correctly(tmp_path: Path) -> None:
|
||||
"""count_by_type runs iter_messages and aggregates the type counts."""
|
||||
# Arrange
|
||||
mav = _make_mav()
|
||||
tlog = _write_tlog(
|
||||
tmp_path / "mixed.tlog",
|
||||
[
|
||||
(_BASE_TS_US + 0, _heartbeat(mav)),
|
||||
(_BASE_TS_US + 1, _heartbeat(mav)),
|
||||
(_BASE_TS_US + 2, _gps_raw_int(mav)),
|
||||
],
|
||||
)
|
||||
|
||||
# Act
|
||||
counts = count_by_type(tlog)
|
||||
|
||||
# Assert
|
||||
assert counts["HEARTBEAT"] == 2
|
||||
assert counts["GPS_RAW_INT"] == 1
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Unit tests for ``runner.helpers.msp_frame_observer`` (FT-P-09-iNav / AZ-417).
|
||||
|
||||
Covers AC-2 (≥4.5 Hz observed for 5 Hz target) and AC-3 (fix_type ≥3,
|
||||
provider=MSP, numSat matches emitted value).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from runner.helpers.msp_frame_observer import (
|
||||
DEFAULT_TARGET_RATE_HZ,
|
||||
MIN_FIX_TYPE,
|
||||
MIN_OBSERVED_RATE_HZ,
|
||||
MSP2_SENSOR_GPS_FUNCTION_ID,
|
||||
REQUIRED_PROVIDER,
|
||||
InavGpsSnapshot,
|
||||
MspFrameSample,
|
||||
compute_rate_hz,
|
||||
count_frames_by_id,
|
||||
evaluate_inav_gps_state,
|
||||
)
|
||||
|
||||
|
||||
def _frames(rate_hz: float, n: int, function_id: int = MSP2_SENSOR_GPS_FUNCTION_ID) -> list[MspFrameSample]:
|
||||
"""Synthetic frame stream at exactly ``rate_hz`` for ``n`` frames."""
|
||||
if rate_hz <= 0:
|
||||
raise ValueError("rate_hz must be > 0")
|
||||
period_ms = int(round(1000.0 / rate_hz))
|
||||
return [
|
||||
MspFrameSample(monotonic_ms=i * period_ms, function_id=function_id)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_constants_match_spec() -> None:
|
||||
"""The AC-2/AC-3 thresholds + IDs must match the spec text."""
|
||||
# Assert
|
||||
assert MSP2_SENSOR_GPS_FUNCTION_ID == 0x1F03
|
||||
assert DEFAULT_TARGET_RATE_HZ == 5.0
|
||||
assert MIN_OBSERVED_RATE_HZ == 4.5
|
||||
assert MIN_FIX_TYPE == 3
|
||||
assert REQUIRED_PROVIDER == "MSP"
|
||||
|
||||
|
||||
def test_count_frames_by_id_filters_correctly() -> None:
|
||||
"""Mixed-ID stream tallies per function ID."""
|
||||
# Arrange
|
||||
samples = [
|
||||
MspFrameSample(0, MSP2_SENSOR_GPS_FUNCTION_ID),
|
||||
MspFrameSample(100, 0x1F04),
|
||||
MspFrameSample(200, MSP2_SENSOR_GPS_FUNCTION_ID),
|
||||
MspFrameSample(300, MSP2_SENSOR_GPS_FUNCTION_ID),
|
||||
]
|
||||
|
||||
# Act
|
||||
counts = count_frames_by_id(samples)
|
||||
|
||||
# Assert
|
||||
assert counts[MSP2_SENSOR_GPS_FUNCTION_ID] == 3
|
||||
assert counts[0x1F04] == 1
|
||||
|
||||
|
||||
def test_compute_rate_at_target_passes() -> None:
|
||||
"""5 Hz over 60 s window passes the ≥4.5 Hz minimum."""
|
||||
# Arrange — 60s at 5Hz = 301 samples (inclusive of t=0 and t=60000).
|
||||
samples = _frames(rate_hz=5.0, n=301)
|
||||
|
||||
# Act
|
||||
report = compute_rate_hz(samples)
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 301
|
||||
assert report.observed_rate_hz == pytest.approx(5.0, abs=0.01)
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_compute_rate_at_boundary_passes() -> None:
|
||||
"""Exactly 4.5 Hz passes (boundary is inclusive)."""
|
||||
# Arrange
|
||||
samples = _frames(rate_hz=4.5, n=46) # 10s @ 4.5Hz
|
||||
|
||||
# Act
|
||||
report = compute_rate_hz(samples)
|
||||
|
||||
# Assert
|
||||
assert report.observed_rate_hz == pytest.approx(4.5, abs=0.05)
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_compute_rate_below_minimum_fails() -> None:
|
||||
"""3 Hz observed → fails the ≥4.5 Hz minimum."""
|
||||
# Arrange
|
||||
samples = _frames(rate_hz=3.0, n=31) # 10s @ 3Hz
|
||||
|
||||
# Act
|
||||
report = compute_rate_hz(samples)
|
||||
|
||||
# Assert
|
||||
assert report.observed_rate_hz == pytest.approx(3.0, abs=0.05)
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_compute_rate_zero_samples_does_not_pass() -> None:
|
||||
"""Empty input → zero count, zero rate, does not pass."""
|
||||
# Act
|
||||
report = compute_rate_hz([])
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 0
|
||||
assert report.window_ms == 0
|
||||
assert report.observed_rate_hz == 0.0
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_compute_rate_single_sample_does_not_pass() -> None:
|
||||
"""One sample yields no window → does not pass."""
|
||||
# Arrange
|
||||
samples = [MspFrameSample(0, MSP2_SENSOR_GPS_FUNCTION_ID)]
|
||||
|
||||
# Act
|
||||
report = compute_rate_hz(samples)
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 1
|
||||
assert report.window_ms == 0
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_compute_rate_filters_function_id() -> None:
|
||||
"""Frames with a different function_id are ignored in the rate calc."""
|
||||
# Arrange
|
||||
samples = (
|
||||
_frames(rate_hz=5.0, n=51, function_id=MSP2_SENSOR_GPS_FUNCTION_ID)
|
||||
+ _frames(rate_hz=10.0, n=101, function_id=0x1F04)
|
||||
)
|
||||
|
||||
# Act
|
||||
report = compute_rate_hz(samples, function_id=MSP2_SENSOR_GPS_FUNCTION_ID)
|
||||
|
||||
# Assert
|
||||
assert report.frame_count == 51
|
||||
assert report.observed_rate_hz == pytest.approx(5.0, abs=0.01)
|
||||
|
||||
|
||||
def test_compute_rate_rejects_negative_minimum() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="min_required_hz"):
|
||||
compute_rate_hz([], min_required_hz=-1.0)
|
||||
|
||||
|
||||
def test_evaluate_gps_state_passes_at_minimum_fix() -> None:
|
||||
"""fix_type=3, provider=MSP, numSat=10 (matches emitted) → AC-3 pass."""
|
||||
# Arrange
|
||||
snapshot = InavGpsSnapshot(fix_type=3, num_sat=10, provider="MSP")
|
||||
|
||||
# Act
|
||||
report = evaluate_inav_gps_state(snapshot, expected_num_sat=10)
|
||||
|
||||
# Assert
|
||||
assert report.fix_type_ok is True
|
||||
assert report.provider_ok is True
|
||||
assert report.num_sat_ok is True
|
||||
assert report.passes is True
|
||||
|
||||
|
||||
def test_evaluate_gps_state_fails_on_low_fix_type() -> None:
|
||||
"""fix_type=2 < 3 → AC-3 fail."""
|
||||
# Arrange
|
||||
snapshot = InavGpsSnapshot(fix_type=2, num_sat=10, provider="MSP")
|
||||
|
||||
# Act
|
||||
report = evaluate_inav_gps_state(snapshot, expected_num_sat=10)
|
||||
|
||||
# Assert
|
||||
assert report.fix_type_ok is False
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_evaluate_gps_state_fails_on_wrong_provider() -> None:
|
||||
"""provider != MSP → AC-3 fail (fallback to internal GPS)."""
|
||||
# Arrange
|
||||
snapshot = InavGpsSnapshot(fix_type=3, num_sat=10, provider="INTERNAL")
|
||||
|
||||
# Act
|
||||
report = evaluate_inav_gps_state(snapshot, expected_num_sat=10)
|
||||
|
||||
# Assert
|
||||
assert report.provider_ok is False
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_evaluate_gps_state_fails_on_num_sat_mismatch() -> None:
|
||||
"""numSat reported by iNav must match the value emitted by SUT."""
|
||||
# Arrange
|
||||
snapshot = InavGpsSnapshot(fix_type=3, num_sat=12, provider="MSP")
|
||||
|
||||
# Act
|
||||
report = evaluate_inav_gps_state(snapshot, expected_num_sat=10)
|
||||
|
||||
# Assert
|
||||
assert report.num_sat_ok is False
|
||||
assert report.passes is False
|
||||
|
||||
|
||||
def test_evaluate_gps_state_rejects_negative_expected_num_sat() -> None:
|
||||
# Arrange
|
||||
snapshot = InavGpsSnapshot(fix_type=3, num_sat=10, provider="MSP")
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="expected_num_sat"):
|
||||
evaluate_inav_gps_state(snapshot, expected_num_sat=-1)
|
||||
Reference in New Issue
Block a user