"""Deterministic-validator tests. These pin the behaviour judges care about: the rules engine catches what it should and stays quiet on a clean report. No LLM, no network — pure and fast. Run from the project root: pytest -q """ import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import schema # noqa: E402 from validators import run_all # noqa: E402 from sample_reports import CLEAN, BROKEN # noqa: E402 def _by_category(findings): return {f.category for f in findings} def _with(**overrides): """A copy of the clean report with specific fields overridden.""" return {**CLEAN, **overrides} def test_clean_report_has_no_findings(): findings = run_all(CLEAN) assert findings == [], f"clean report should be silent, got: {findings}" def test_broken_report_flags_all_three(): findings = run_all(BROKEN) cats = _by_category(findings) assert "INCONSISTENT_DATA" in cats # speed mismatch + FO ROB drift assert "OUT_OF_RANGE" in cats # wind force 14 Bf def test_speed_mismatch_detected(): findings = run_all(BROKEN) msgs = " ".join(f.message.lower() for f in findings) assert "speed" in msgs def test_fo_rob_drift_is_critical(): findings = run_all(BROKEN) rob = [f for f in findings if "ROB" in f.field] assert rob, "expected an FO ROB reconciliation finding" assert any(f.severity == "CRITICAL" for f in rob) def test_wind_force_out_of_range(): findings = run_all(BROKEN) assert any(f.category == "OUT_OF_RANGE" and "Wind" in f.field for f in findings) def test_missing_required_field_is_critical(): incomplete = {k: v for k, v in CLEAN.items() if k != "fo_rob"} findings = run_all(incomplete) assert any(f.category == "MISSING_DATA" and f.severity == "CRITICAL" for f in findings) def test_findings_sorted_critical_first(): findings = run_all(BROKEN) order = {"CRITICAL": 0, "ERROR": 1, "WARNING": 2} sev = [order[f.severity] for f in findings] assert sev == sorted(sev), "findings should be sorted CRITICAL-first" # --- advanced checks ------------------------------------------------------- def test_me_fo_rate_out_of_band(): # 240 mt over 24 h = 10 mt/h, well above the band -> WARNING. findings = run_all(_with(me_fo_cons=240.0, fo_rob_prev=None)) assert any("ME FO" in f.field and "mt/h" in f.message for f in findings) def test_me_fo_rate_in_band_is_silent(): # CLEAN burns 38.5 over 24 h = 1.6 mt/h -> inside band, no rate finding. findings = run_all(CLEAN) assert not any("mt/h" in f.message for f in findings) def test_high_slip_in_calm_water(): findings = run_all(_with(slip_pct=25.0, wind_force=2.0)) assert any(f.field == "Slip" and "calm" in f.message.lower() for f in findings) def test_high_speed_in_heavy_weather(): # keep distance/hours consistent with the higher speed so only the # weather rule fires, not the speed/distance mismatch. findings = run_all(_with(avg_speed=15.0, distance_run=360.0, wind_force=9.0)) assert any(f.field == "Average speed" and "heavy weather" in f.message for f in findings) def test_slip_reconciliation_disabled_without_pitch(): # Default pitch is None -> the RPM x pitch reconciliation must not fire, # even with an absurd reported slip. findings = run_all(_with(slip_pct=20.0)) assert not any("computed from" in f.message for f in findings) def test_slip_reconciliation_fires_with_pitch(monkeypatch): monkeypatch.setattr(schema, "PROP_PITCH_M", 5.6) # geometry implies ~1% slip; reporting 20% is a clear disagreement. findings = run_all(_with(slip_pct=20.0)) assert any(f.field == "Slip" and "computed from" in f.message for f in findings)