document-extract-agent / tests /test_schema.py
kennethzychew's picture
phase 1.1: pydantic document schema + normalizers
a003369
Raw
History Blame Contribute Delete
8.88 kB
"""Unit tests for the document schema and its normalizers.
Covers the acceptance criterion for build-plan task 1.1: valid dicts parse,
and malformed money/date values are handled (absent -> ``None``, present but
unparseable -> ``ValidationError``).
"""
from __future__ import annotations
from datetime import date
import pytest
from pydantic import ValidationError
from doc_agent.schema.models import Document, LineItem
def test_minimal_document_uses_defaults() -> None:
"""An empty payload yields a fully-defaulted, pipeline-ready document."""
document = Document()
assert document.doc_type == "other"
assert document.vendor_name is None
assert document.invoice_number is None
assert document.document_date is None
assert document.line_items == []
assert document.total is None
assert document.field_confidence == {}
assert document.validation == {}
assert document.decision is None
def test_full_valid_dict_parses() -> None:
"""A realistic, slightly messy dict parses and normalizes end-to-end."""
document = Document.model_validate(
{
"doc_type": "Invoice",
"vendor_name": " Acme Corp ",
"vendor_address": "1 Market St",
"invoice_number": "INV-2024-001",
"document_date": "2024-01-15",
"due_date": "2024/02/14",
"currency": "usd",
"line_items": [
{"description": "Widget", "quantity": "2", "unit_price": "10.00", "amount": "20.00"},
],
"subtotal": "$20.00",
"tax": "1.60",
"total": "$21.60",
}
)
assert document.doc_type == "invoice"
assert document.vendor_name == "Acme Corp"
assert document.invoice_number == "INV-2024-001"
assert document.document_date == date(2024, 1, 15)
assert document.due_date == date(2024, 2, 14)
assert document.currency == "USD"
assert document.subtotal == pytest.approx(20.00)
assert document.tax == pytest.approx(1.60)
assert document.total == pytest.approx(21.60)
assert len(document.line_items) == 1
item = document.line_items[0]
assert item.description == "Widget"
assert item.quantity == pytest.approx(2.0)
assert item.unit_price == pytest.approx(10.0)
assert item.amount == pytest.approx(20.0)
@pytest.mark.parametrize(
("raw", "expected"),
[
("$1,234.56", 1234.56), # US: symbol + thousands + decimal
("1.234,56", 1234.56), # European: dot grouping, comma decimal
("1,234", 1234.0), # bare thousands grouping
("1,234,567", 1234567.0), # multi-group thousands
("12,50", 12.5), # decimal comma
("1.234.567", 1234567.0), # European dot grouping
("USD 99.99", 99.99), # leading currency code
(" 42 ", 42.0), # surrounding whitespace
("(123.45)", -123.45), # accounting-style negative
("-50.00", -50.0), # explicit negative
("0", 0.0), # zero is a real value, not absent
(2, 2.0), # native int
(3.5, 3.5), # native float
],
)
def test_money_normalization(raw: object, expected: float) -> None:
"""Monetary strings strip symbols/separators and parse to ``float``."""
document = Document.model_validate({"total": raw})
assert document.total == pytest.approx(expected)
@pytest.mark.parametrize("token", ["", " ", "N/A", "na", "-", "--", "none", "null"])
def test_money_null_tokens_become_none(token: str) -> None:
"""Absent-value sentinels normalize to ``None`` rather than raising."""
document = Document.model_validate({"total": token})
assert document.total is None
@pytest.mark.parametrize("bad", ["abc", "12.3.4", "1,2,3", "twelve"])
def test_money_unparseable_raises(bad: str) -> None:
"""A present-but-unparseable number raises (routes the doc to review).
Malformed grouping like ``12.3.4`` must not be silently mangled into a
plausible-but-wrong number; it raises so the document routes to review.
"""
with pytest.raises(ValidationError):
Document.model_validate({"total": bad})
def test_money_rejects_non_finite() -> None:
"""Infinite/NaN numeric inputs are rejected."""
with pytest.raises(ValidationError):
Document.model_validate({"total": float("inf")})
def test_money_rejects_boolean() -> None:
"""Booleans are not accepted as monetary values."""
with pytest.raises(ValidationError):
Document.model_validate({"total": True})
@pytest.mark.parametrize(
("raw", "expected"),
[
("2024-01-15", date(2024, 1, 15)), # ISO 8601
("2024-01-15T13:45:00", date(2024, 1, 15)), # ISO datetime
("2024/01/15", date(2024, 1, 15)),
("15/01/2024", date(2024, 1, 15)), # day-first
("04/13/2024", date(2024, 4, 13)), # day-first impossible -> month-first
("15-01-2024", date(2024, 1, 15)),
("15.01.2024", date(2024, 1, 15)),
("15 Jan 2024", date(2024, 1, 15)),
("January 15, 2024", date(2024, 1, 15)),
("Jan 15, 2024", date(2024, 1, 15)),
("20240115", date(2024, 1, 15)),
(date(2024, 1, 15), date(2024, 1, 15)), # passthrough
],
)
def test_date_normalization(raw: object, expected: date) -> None:
"""Common date formats normalize to ``datetime.date``."""
document = Document.model_validate({"document_date": raw})
assert document.document_date == expected
def test_date_ambiguous_resolves_day_first() -> None:
"""An ambiguous DD/MM vs MM/DD string resolves day-first."""
document = Document.model_validate({"document_date": "03/04/2024"})
assert document.document_date == date(2024, 4, 3)
@pytest.mark.parametrize("token", ["", " ", "N/A", "-", "none"])
def test_date_null_tokens_become_none(token: str) -> None:
"""Absent-value date sentinels normalize to ``None``."""
document = Document.model_validate({"document_date": token})
assert document.document_date is None
@pytest.mark.parametrize("bad", ["not-a-date", "2024-13-40", "31/31/2024"])
def test_date_unparseable_raises(bad: str) -> None:
"""A present-but-unparseable date raises."""
with pytest.raises(ValidationError):
Document.model_validate({"document_date": bad})
@pytest.mark.parametrize(
("raw", "expected"),
[("receipt", "receipt"), ("INVOICE", "invoice"), (" Other ", "other")],
)
def test_doc_type_normalized(raw: str, expected: str) -> None:
"""``doc_type`` is lower-cased and trimmed."""
document = Document.model_validate({"doc_type": raw})
assert document.doc_type == expected
@pytest.mark.parametrize("raw", ["purchase_order", "statement", "unknown"])
def test_unknown_doc_type_falls_back_to_other(raw: str) -> None:
"""An unrecognized ``doc_type`` maps to "other" instead of raising."""
document = Document.model_validate({"doc_type": raw})
assert document.doc_type == "other"
def test_currency_is_upper_cased() -> None:
"""Currency codes are upper-cased; blank values become ``None``."""
assert Document.model_validate({"currency": "sgd"}).currency == "SGD"
assert Document.model_validate({"currency": " "}).currency is None
@pytest.mark.parametrize("token", ["", " ", "N/A", "-"])
def test_blank_text_fields_become_none(token: str) -> None:
"""Blank/sentinel free-text fields normalize to ``None``."""
document = Document.model_validate(
{"vendor_name": token, "vendor_address": token, "invoice_number": token}
)
assert document.vendor_name is None
assert document.vendor_address is None
assert document.invoice_number is None
def test_numeric_invoice_number_becomes_string() -> None:
"""A numeric invoice number is preserved as text, not coerced away."""
document = Document.model_validate({"invoice_number": 100245})
assert document.invoice_number == "100245"
def test_line_item_defaults_and_normalization() -> None:
"""``LineItem`` defaults are all ``None`` and its numbers normalize."""
assert LineItem() == LineItem(
description=None, quantity=None, unit_price=None, amount=None
)
item = LineItem.model_validate(
{"description": " Coffee ", "quantity": "3", "unit_price": "$2.50", "amount": "7.50"}
)
assert item.description == "Coffee"
assert item.quantity == pytest.approx(3.0)
assert item.unit_price == pytest.approx(2.5)
assert item.amount == pytest.approx(7.5)
def test_json_roundtrip_serializes_dates_as_iso() -> None:
"""JSON serialization renders dates as ISO 8601 and round-trips cleanly."""
document = Document.model_validate(
{"document_date": "15/01/2024", "total": "$21.60", "currency": "usd"}
)
payload = document.model_dump_json()
assert '"document_date":"2024-01-15"' in payload
restored = Document.model_validate_json(payload)
assert restored == document