mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:51:12 +00:00
e87fb37a2c
- 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
141 lines
4.7 KiB
Python
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)
|