Spaces:
Sleeping
Sleeping
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
|