Files
gps-denied-onboard/tests/test_log_schemas.py
T
Yuzviak e87fb37a2c test(02-07): add unit tests for boundary-log schemas (AC-02, OBS-01)
- Create tests/test_log_schemas.py with 20 tests (17 under -m ac)
- Module-level pytestmark = [pytest.mark.unit]
- @pytest.mark.ac tags: AC-4.3, AC-1.4, AC-2.1b, AC-6.3
- Tests: round-trip model_dump, extra-field rejection, Literal vocab
  validation, Field bounds (fix_type 0..6, status_code 100..599), frozen
2026-05-11 18:50:31 +03:00

141 lines
4.7 KiB
Python

"""Unit tests for boundary-log Pydantic schemas in src/gps_denied/obs/log_schemas.py.
Phase 2 / OBS-01 + AC-02. Producer wiring lives in Phase 3 (AnchorDecision),
Phase 5 (MavlinkGpsInputEmitted), and Phase 6 (ApiRequestCompleted); this file
asserts the schema CONTRACT today.
"""
import pytest
from pydantic import ValidationError
from gps_denied.obs import (
AnchorDecision,
ApiRequestCompleted,
MavlinkGpsInputEmitted,
)
pytestmark = [pytest.mark.unit]
# ---------------------------------------------------------------
# MavlinkGpsInputEmitted — AC-4.3 (GPS_INPUT contract), AC-1.4 (source_label vocab)
# ---------------------------------------------------------------
VALID_GPS_INPUT = dict(
lat_deg=49.0, lon_deg=32.0, alt_m=400.0,
fix_type=3, horiz_accuracy_m=12.0,
source_label="satellite_anchored",
anchor_age_ms=120, cov_semi_major_m=11.5,
)
@pytest.mark.ac("AC-4.3")
@pytest.mark.ac("AC-1.4")
def test_mavlink_gps_input_round_trip():
"""Construction + model_dump(mode='json') round-trip is the producer pattern."""
rec = MavlinkGpsInputEmitted(**VALID_GPS_INPUT)
d = rec.model_dump(mode="json")
assert d["lat_deg"] == 49.0
assert d["fix_type"] == 3
assert d["source_label"] == "satellite_anchored"
@pytest.mark.ac("AC-1.4")
@pytest.mark.parametrize("label", [
"satellite_anchored", "vo_extrapolated", "dead_reckoned",
])
def test_mavlink_source_label_accepts_canonical_vocab(label):
"""AC-1.4 — exactly three categorical labels are valid."""
rec = MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "source_label": label})
assert rec.source_label == label
@pytest.mark.ac("AC-1.4")
def test_mavlink_source_label_rejects_unknown():
"""AC-1.4 — non-canonical source_label fails validation."""
with pytest.raises(ValidationError) as exc:
MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "source_label": "GPS"})
assert "source_label" in str(exc.value)
@pytest.mark.ac("AC-4.3")
@pytest.mark.parametrize("fix_type", [-1, 7, 100])
def test_mavlink_fix_type_bounds(fix_type):
"""MAVLink GPS fix_type is bounded 0..6."""
with pytest.raises(ValidationError):
MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "fix_type": fix_type})
@pytest.mark.ac("AC-4.3")
def test_mavlink_extra_field_rejected():
"""extra='forbid' — producer typo / drift fails fast."""
with pytest.raises(ValidationError):
MavlinkGpsInputEmitted(**VALID_GPS_INPUT, unexpected_field=42)
@pytest.mark.ac("AC-4.3")
def test_mavlink_record_is_frozen():
"""Records are facts; mutation after construction is forbidden."""
rec = MavlinkGpsInputEmitted(**VALID_GPS_INPUT)
with pytest.raises(ValidationError):
rec.fix_type = 1 # type: ignore[misc]
# ---------------------------------------------------------------
# ApiRequestCompleted — Phase 6 consumer (REST middleware)
# ---------------------------------------------------------------
@pytest.mark.ac("AC-6.3")
def test_api_request_round_trip():
rec = ApiRequestCompleted(
path="/flights",
method="POST",
status_code=201,
duration_ms=12.5,
)
d = rec.model_dump(mode="json")
assert d["method"] == "POST"
assert d["status_code"] == 201
def test_api_request_rejects_unknown_method():
with pytest.raises(ValidationError):
ApiRequestCompleted(path="/x", method="OPTIONS", status_code=200, duration_ms=1.0)
def test_api_request_status_bounds():
with pytest.raises(ValidationError):
ApiRequestCompleted(path="/x", method="GET", status_code=99, duration_ms=1.0)
with pytest.raises(ValidationError):
ApiRequestCompleted(path="/x", method="GET", status_code=600, duration_ms=1.0)
# ---------------------------------------------------------------
# AnchorDecision — VERIFY-02 (Phase 3 consumer)
# ---------------------------------------------------------------
@pytest.mark.ac("AC-2.1b")
def test_anchor_decision_accept_round_trip():
rec = AnchorDecision(decision="accept", reason="ok", n_inliers=42, mre_px=0.8)
d = rec.model_dump(mode="json")
assert d["decision"] == "accept"
assert d["reason"] == "ok"
@pytest.mark.ac("AC-2.1b")
@pytest.mark.parametrize("reason", [
"ok",
"too_few_inliers",
"mre_above_threshold",
"degenerate_homography",
"freshness_expired",
])
def test_anchor_decision_accepts_verify02_vocab(reason):
"""REQUIREMENTS.md VERIFY-02 — exactly five reject-reason values are valid."""
rec = AnchorDecision(decision="reject", reason=reason, n_inliers=0, mre_px=99.0)
assert rec.reason == reason
def test_anchor_decision_rejects_unknown_reason():
with pytest.raises(ValidationError):
AnchorDecision(decision="reject", reason="too_much_drift", n_inliers=0, mre_px=99.0)