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