| """End-to-end pipeline tests (pytest). |
| |
| Deterministic only -- no model required. Vision-extraction accuracy is checked |
| manually against the scan fixture (see README). |
| """ |
|
|
| import json |
| import os |
| import sys |
|
|
| import pytest |
|
|
| ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| sys.path.insert(0, ROOT) |
| SAMPLES = os.path.join(ROOT, "samples") |
|
|
| from categorize import categorize_rules, enforce_closed_set |
| from constants import CATEGORY_SET, SUSPENSE |
| from excel_export import write_excel |
| from extraction import extract_from_pdf, parse_date |
| from tally_export import generate_tally_xml |
| from validate import reconcile |
|
|
|
|
| @pytest.fixture(scope="module") |
| def truth(): |
| with open(os.path.join(SAMPLES, "sample_digital_truth.json"), encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| |
| def test_extraction_digital_exact(truth): |
| txns, meta = extract_from_pdf(os.path.join(SAMPLES, "sample_digital.pdf")) |
| assert meta["path"] == "text-layer" |
| assert meta["gpu_used"] is False |
| assert len(txns) == len(truth) == 40 |
| for got, want in zip(txns, truth): |
| assert got["date"] == want["date"] |
| assert got["debit"] == want["debit"] |
| assert got["credit"] == want["credit"] |
| assert got["balance"] == want["balance"] |
| assert got["narration"].strip() == want["narration"].strip() |
|
|
|
|
| |
| def test_reconcile_clean(truth): |
| txns = [dict(t) for t in truth] |
| result = reconcile(txns) |
| assert result["reconciled"] == result["total"] |
| assert result["total"] == 39 |
| assert "✅" in result["banner"] |
|
|
|
|
| def test_reconcile_flags_corrupted_row(truth): |
| txns = [dict(t) for t in truth] |
| txns[10]["balance"] = txns[10]["balance"] + 999.0 |
| result = reconcile(txns) |
| assert txns[10]["flags"], "corrupted row must be flagged" |
| |
| assert result["reconciled"] < result["total"] |
|
|
|
|
| |
| @pytest.mark.parametrize("raw", ["01/04/2026", "01-04-26", "1 Apr 2026", |
| "2026-04-01", "1-April-2026", "01.04.2026"]) |
| def test_date_parsing(raw): |
| assert parse_date(raw) == "2026-04-01" |
|
|
|
|
| |
| CANNED = [ |
| ("SALARY APR 2026 - STAFF PAYROLL", None, 50000.0, "Salary & Wages"), |
| ("UPI/DR/123/SHARMA RENT/office rent", 35000.0, None, "Office Rent"), |
| ("GST PMT-CBIC-GSTR3B", 18900.0, None, "GST Payment"), |
| ("TDS PMT-CPC Q4", 9400.0, None, "TDS Payment"), |
| ("INCOME TAX-ADVANCE TAX CBDT", 35000.0, None, "Income Tax Payment"), |
| ("ATM CSH WDL/AXIS", 10000.0, None, "Cash Withdrawal"), |
| ("CSH DEP/BRANCH CASH DEPOSIT", None, 50000.0, "Cash Deposit"), |
| ("BSNL BROADBAND INTERNET BILL", 1499.0, None, "Telephone & Internet"), |
| ("ELECTRICITY KESCO BILL", 8920.0, None, "Electricity & Utilities"), |
| ("BANK CHRG-SMS CHGS", 23.6, None, "Bank Charges"), |
| ("LIC OF INDIA PREMIUM POLICY", 9800.0, None, "Insurance"), |
| ("HDFC HOME LOAN EMI", 28750.0, None, "Loan EMI"), |
| ("INT CREDIT-FIXED DEPOSIT INTEREST", None, 4521.0, "Interest Received"), |
| ("IRCTC RAIL TICKET TRAVEL", 3450.0, None, "Travel & Conveyance"), |
| ("DRAWINGS-PROPRIETOR PERSONAL", 30000.0, None, "Drawings"), |
| ] |
|
|
|
|
| @pytest.mark.parametrize("narration,debit,credit,expected", CANNED) |
| def test_categorization_rules(narration, debit, credit, expected): |
| txn = {"narration": narration, "debit": debit, "credit": credit} |
| result = categorize_rules(txn) |
| assert result is not None, f"no rule matched: {narration}" |
| assert result[0] == expected |
|
|
|
|
| |
| def test_closed_set_rejects_invented_category(): |
| cat, conf = enforce_closed_set("Crypto Trading", 0.99) |
| assert cat == SUSPENSE |
| cat, conf = enforce_closed_set("Office Rent", 0.3) |
| assert cat == SUSPENSE |
| cat, conf = enforce_closed_set("Office Rent", 0.9) |
| assert cat == "Office Rent" |
| assert cat in CATEGORY_SET |
|
|
|
|
| |
| def test_tally_xml_wellformed_and_balanced(truth): |
| from lxml import etree |
| txns = [dict(t, category="Suspense / Unclassified", |
| voucher_type=("Payment" if t["debit"] else "Receipt")) |
| for t in truth] |
| xml = generate_tally_xml(txns) |
| root = etree.fromstring(xml.encode("utf-8")) |
| vouchers = root.findall(".//VOUCHER") |
| assert len(vouchers) == len(truth) |
| for v in vouchers: |
| amounts = [float(a.text) for a in v.findall(".//AMOUNT")] |
| assert len(amounts) == 2 |
| assert abs(sum(amounts)) < 0.01, "debit/credit must net to zero per voucher" |
|
|
|
|
| |
| def test_excel_export(truth, tmp_path): |
| from openpyxl import load_workbook |
| txns = [dict(t, category="Suspense / Unclassified") for t in truth] |
| out = os.path.join(tmp_path, "out.xlsx") |
| write_excel(txns, out) |
| wb = load_workbook(out) |
| ws = wb.active |
| |
| assert ws.max_row == 1 + len(truth) + 1 |
| total_debit = sum(t["debit"] or 0 for t in truth) |
| |
| assert abs(ws.cell(row=ws.max_row, column=4).value - round(total_debit, 2)) < 0.01 |
|
|