From e87fb37a2c57e60c3826d1d2ff94bb3554de7a7b Mon Sep 17 00:00:00 2001 From: Yuzviak Date: Mon, 11 May 2026 18:50:31 +0300 Subject: [PATCH] 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 --- tests/test_log_schemas.py | 140 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/test_log_schemas.py diff --git a/tests/test_log_schemas.py b/tests/test_log_schemas.py new file mode 100644 index 0000000..32db66e --- /dev/null +++ b/tests/test_log_schemas.py @@ -0,0 +1,140 @@ +"""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)