File size: 15,608 Bytes
fd5e760
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
 
fd5e760
6eb59bb
fd5e760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6eb59bb
fd5e760
 
 
 
6eb59bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd5e760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0889d7
 
 
fd5e760
 
 
 
 
 
a0889d7
 
fd5e760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0889d7
fd5e760
 
 
 
 
 
a0889d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
a0889d7
 
 
 
 
 
 
 
 
 
 
 
 
 
fd5e760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""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