"""Unit tests for the hard/soft validation rules. Covers the acceptance criterion for build-plan task 1.2: reconciling totals pass H2/H3, mismatches fail, and soft failures are recorded without forcing review. Also exercises the monetary epsilon, the skip semantics for absent inputs, and the report's serialization. """ from __future__ import annotations from datetime import date import pytest from docfield.schema.models import Document from docfield.validation.rules import ( MONETARY_ABS_EPSILON, MONETARY_PER_TERM_EPSILON, ValidationReport, money_close, validate, ) def _status(report: ValidationReport, code: str) -> str: """Return the status string for a rule code (fails the test if absent).""" result = report.by_code(code) assert result is not None, f"missing rule {code}" return result.status # --- money_close --------------------------------------------------------------- def test_money_close_within_absolute_epsilon() -> None: """Differences at or under the absolute floor compare equal.""" assert money_close(10.00, 10.00 + MONETARY_ABS_EPSILON) assert money_close(10.00, 10.00 - MONETARY_ABS_EPSILON) def test_money_close_tolerates_cents_rejects_larger_gaps() -> None: """Cent-level gaps reconcile; a half-unit gap on a small amount does not.""" assert money_close(10.00, 10.02) assert not money_close(10.00, 10.50) def test_money_close_tiny_amounts_use_absolute_floor() -> None: """The absolute epsilon governs when no extra rounded terms are involved.""" assert money_close(1.00, 1.01) assert not money_close(1.00, 1.05) def test_money_close_does_not_scale_with_amount() -> None: """A large amount buys no extra tolerance -- the defect FC-2 records. Under the old rule the tolerance was max(0.02, 0.005 * amount), so a 10,000 invoice absorbed a 40.00 discrepancy and a 500 receipt absorbed 2.00. Rounding does not work that way: the error in a sum depends on how many figures were rounded, not on how large they are. This is the same defect already fixed on the measurement side; the assertion below is the decision side of it. """ assert not money_close(10_000.00, 10_040.00) assert not money_close(500.00, 502.00) assert not money_close(100_000.00, 100_000.50) # The floor still applies, at any scale. assert money_close(10_000.00, 10_000.02) def test_money_close_allowance_grows_with_term_count() -> None: """Each additional independently-rounded term adds half a cent.""" # 10 line items -> 0.02 + 10 * 0.005 = 0.07 assert money_close(100.00, 100.07, n_terms=10) assert not money_close(100.00, 100.08, n_terms=10) # The same gap is rejected when only the base figures are involved. assert not money_close(100.00, 100.07, n_terms=0) def test_money_close_term_count_is_clamped_at_zero() -> None: """A zero or negative term count never shrinks the floor.""" assert money_close(10.00, 10.02, n_terms=0) assert money_close(10.00, 10.02, n_terms=-5) def test_money_close_boundary_is_decided_in_cents_not_floats() -> None: """The FC-1 residual sits exactly on the floor and must resolve consistently. 7.20 + 0.43 is 0.020000000000000462 from 7.65 in IEEE 754 but exactly 0.02 in decimal. Rounding the residual to cents first makes the verdict a property of the money rather than of the binary representation -- so this document passes for a stated reason, not by 4.6e-16 of float noise. """ assert round(abs((7.20 + 0.43) - 7.65), 2) == MONETARY_ABS_EPSILON assert money_close(7.20 + 0.43, 7.65) # A cent beyond the floor is still rejected (7.62 vs 7.65 -> residual 0.03). assert round(abs((7.20 + 0.42) - 7.65), 2) == 0.03 assert not money_close(7.20 + 0.42, 7.65) def test_per_term_epsilon_is_half_a_cent() -> None: """Pin the constant: a cent-rounded figure carries up to half a cent.""" assert MONETARY_PER_TERM_EPSILON == 0.005 # --- H2: subtotal + tax == total ---------------------------------------------- def test_h2_reconciling_totals_pass() -> None: """Reconciling subtotal + tax == total passes H2 (acceptance criterion).""" document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.00"}) report = validate(document) assert _status(report, "H2") == "pass" assert not report.hard_failed def test_h2_mismatch_fails_and_forces_review() -> None: """A totals mismatch fails H2 and marks the report hard-failed.""" document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"}) report = validate(document) assert _status(report, "H2") == "fail" assert report.hard_failed assert "H2" in [r.code for r in report.hard_failures] def test_h2_within_epsilon_passes() -> None: """A sub-cent rounding gap still reconciles under the epsilon.""" document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.01"}) assert _status(validate(document), "H2") == "pass" def test_h2_skipped_when_inputs_absent() -> None: """H2 is skipped (not failed) when an input is missing.""" document = Document.model_validate({"subtotal": "100.00", "total": "107.00"}) # no tax report = validate(document) assert _status(report, "H2") == "skip" assert not report.hard_failed # --- H3: line items reconcile -------------------------------------------------- def test_h3_line_items_reconcile_to_subtotal() -> None: """Summed line amounts matching the subtotal passes H3 (acceptance).""" document = Document.model_validate( { "line_items": [ {"description": "A", "amount": "40.00"}, {"description": "B", "amount": "60.00"}, ], "subtotal": "100.00", "tax": "7.00", "total": "107.00", } ) report = validate(document) assert _status(report, "H3") == "pass" assert _status(report, "H2") == "pass" assert not report.hard_failed def test_h3_reconciles_to_total_when_no_subtotal() -> None: """With no subtotal, H3 reconciles the line sum against the total.""" document = Document.model_validate( { "line_items": [{"amount": "10.00"}, {"amount": "15.00"}], "total": "25.00", } ) assert _status(validate(document), "H3") == "pass" def test_h3_mismatch_fails() -> None: """Line amounts that do not sum to the subtotal fail H3.""" document = Document.model_validate( { "line_items": [{"amount": "40.00"}, {"amount": "60.00"}], "subtotal": "150.00", } ) report = validate(document) assert _status(report, "H3") == "fail" assert report.hard_failed def test_h3_skipped_without_line_items() -> None: """No line items means H3 cannot run and is skipped.""" document = Document.model_validate({"subtotal": "100.00", "total": "100.00"}) assert _status(validate(document), "H3") == "skip" def test_h3_skipped_when_an_amount_missing() -> None: """A single missing line amount makes the sum incomplete: skip, not fail.""" document = Document.model_validate( { "line_items": [{"amount": "40.00"}, {"description": "no amount"}], "subtotal": "40.00", } ) assert _status(validate(document), "H3") == "skip" # --- H1 / H4: critical-field guards ------------------------------------------- def test_h1_passes_for_well_typed_document() -> None: """A normally-parsed document satisfies the H1 type guard.""" document = Document.model_validate({"total": "10.00", "tax": "1.00", "invoice_number": "X1"}) assert _status(validate(document), "H1") == "pass" def test_h4_passes_when_total_present_and_nonnegative() -> None: """A present, non-negative total passes H4.""" assert _status(validate(Document.model_validate({"total": "0.00"})), "H4") == "pass" def test_h4_fails_when_total_missing() -> None: """A missing total is a hard failure (never safe to auto-accept).""" report = validate(Document.model_validate({"vendor_name": "Acme"})) assert _status(report, "H4") == "fail" assert report.hard_failed def test_h4_fails_when_total_negative() -> None: """A negative total is a hard failure.""" report = validate(Document.model_validate({"total": "-5.00"})) assert _status(report, "H4") == "fail" assert report.hard_failed # --- Soft rules: recorded without forcing review ------------------------------ def test_soft_failures_do_not_force_review() -> None: """Soft failures are recorded but never set hard_failed (acceptance). This document reconciles arithmetically (hard rules pass) but is missing the vendor name and date and has no checkable line items. The decision path must stay open: hard_failed is False. Its absent currency is skipped, not failed (see the S2 tests below), so S2 is deliberately absent from the failed set. """ document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.00"}) report = validate(document) assert not report.hard_failed failed_codes = {r.code for r in report.soft_failures} assert {"S1", "S3"} <= failed_codes assert "S2" not in failed_codes def test_s1_present_date_is_plausible_without_reference() -> None: """With no ``today`` reference, a present date passes S1 (presence only).""" document = Document.model_validate({"document_date": "2024-01-15", "total": "1.00"}) assert _status(validate(document), "S1") == "pass" def test_s1_future_date_fails_against_reference() -> None: """A date past today + grace fails S1 when a reference is supplied.""" document = Document.model_validate({"document_date": "2030-01-01", "total": "1.00"}) report = validate(document, today=date(2024, 1, 15)) assert _status(report, "S1") == "fail" assert not report.hard_failed # still soft def test_s1_missing_date_fails_soft() -> None: """A missing document_date is a soft failure.""" assert _status(validate(Document.model_validate({"total": "1.00"})), "S1") == "fail" def test_s2_known_currency_passes_unknown_fails() -> None: """S2 passes a known ISO code and fails a stated non-currency.""" good = Document.model_validate({"currency": "sgd", "total": "1.00"}) bad = Document.model_validate({"currency": "ZZZ", "total": "1.00"}) assert _status(validate(good), "S2") == "pass" assert _status(validate(bad), "S2") == "fail" def test_s2_absent_currency_is_skipped_not_failed() -> None: """A document that states no currency is not penalised for it. Whether an issuer prints a currency code says nothing about whether the document's arithmetic is right, and ``None`` is the correct extraction for a receipt that omits one. Failing it would confuse "nothing was stated" with "what was stated looks wrong", costing recall for no precision gain. """ document = Document.model_validate({"total": "1.00"}) report = validate(document) assert _status(report, "S2") == "skip" assert "S2" not in {r.code for r in report.soft_failures} def test_s2_blank_currency_is_skipped_not_failed() -> None: """Blank/sentinel currency strings normalize to None and are also skipped.""" for blank in ("", " ", "N/A", "-"): document = Document.model_validate({"currency": blank, "total": "1.00"}) assert _status(validate(document), "S2") == "skip", blank def test_s2_absence_does_not_reduce_the_confidence_score() -> None: """The skip must actually keep the score whole, not merely relabel it. A skipped rule is not a soft failure, so it carries no scoring penalty. This pins the behaviour end to end -- rule status through to the number routing consumes -- so a future change that reintroduced the penalty via the scorer would fail here rather than silently costing recall again. """ from docfield.routing.score import score with_currency = Document.model_validate( {"vendor_name": "Acme", "document_date": "2019-01-15", "currency": "MYR", "total": "1.00"} ) without = Document.model_validate( {"vendor_name": "Acme", "document_date": "2019-01-15", "total": "1.00"} ) assert score(with_currency, validate(with_currency), None) == pytest.approx( score(without, validate(without), None) ) def test_s3_vendor_present_passes() -> None: """A present vendor name passes S3.""" document = Document.model_validate({"vendor_name": "Acme Corp", "total": "1.00"}) assert _status(validate(document), "S3") == "pass" def test_s4_per_line_arithmetic() -> None: """S4 passes consistent lines and fails when a line does not reconcile.""" good = Document.model_validate( {"line_items": [{"quantity": "2", "unit_price": "5.00", "amount": "10.00"}], "total": "10.00"} ) bad = Document.model_validate( {"line_items": [{"quantity": "2", "unit_price": "5.00", "amount": "11.00"}], "total": "11.00"} ) assert _status(validate(good), "S4") == "pass" report = validate(bad) assert _status(report, "S4") == "fail" assert not report.hard_failed # S4 is soft def test_s4_skipped_without_full_line_fields() -> None: """S4 is skipped when no line item carries quantity, unit_price, and amount.""" document = Document.model_validate( {"line_items": [{"description": "X", "amount": "10.00"}], "total": "10.00"} ) assert _status(validate(document), "S4") == "skip" # --- Report shape -------------------------------------------------------------- def test_report_has_one_result_per_rule() -> None: """Every rule reports exactly once, in a stable set of codes.""" report = validate(Document.model_validate({"total": "1.00"})) codes = [r.code for r in report.results] assert codes == ["H1", "H2", "H3", "H4", "S1", "S2", "S3", "S4"] def test_report_to_dict_is_serializable() -> None: """The report serializes to a plain dict suitable for Document.validation.""" document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"}) payload = validate(document).to_dict() assert payload["hard_failed"] is True assert "H2" in payload["hard_failures"] assert isinstance(payload["results"], list) assert len(payload["results"]) == 8 first = payload["results"][0] assert set(first) == {"code", "severity", "status", "message"} # Round-trips through JSON cleanly (no non-serializable objects). import json assert json.loads(json.dumps(payload)) == payload def test_validate_does_not_mutate_document() -> None: """Validation is pure: it leaves the input document untouched.""" document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"}) validate(document) assert document.validation == {} assert document.decision is None @pytest.mark.parametrize( ("subtotal", "tax", "total", "expect_pass"), [ ("19.99", "1.60", "21.59", True), ("19.99", "1.60", "21.60", True), # 0.01 rounding, within epsilon ("19.99", "1.60", "25.00", False), ], ) def test_h2_epsilon_boundary(subtotal: str, tax: str, total: str, expect_pass: bool) -> None: """H2 tolerates cent-level rounding but rejects real mismatches.""" document = Document.model_validate({"subtotal": subtotal, "tax": tax, "total": total}) status = _status(validate(document), "H2") assert (status == "pass") is expect_pass