SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
8.8 KB · 222 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : test_core.py6# Description : Unit tests — money, provenance, ALSL strictness, diagnostics, ledger, backends.7# =============================================================================8"""Unit tests for the non-golden guarantees: float rejection, SSA provenance,9ALSL loader strictness, clang-style diagnostics, the hash-chained ledger,10reversal semantics, and the CSV/native backends."""11from __future__ import annotations1213import json14from datetime import date15from decimal import Decimal16from pathlib import Path1718import pytest1920from aic.compiler import compile_document21from aic.diagnostics import CompilationError22from alsl.loader import AlslLoadError, load_policy_set23from backends.generic_csv.backend import GenericCsvBackend24from backends.native.backend import NativeLedgerBackend25from core.events import AirDocument26from core.money import CurrencyMismatchError, Money, RoundingMode, money27from kernel.ledger import Ledger28from kernel.reporting import balance_sheet, income_statement, trial_balance2930ROOT = Path(__file__).resolve().parents[1]31POLICY_FILE = ROOT / "alsl" / "policies" / "ca-qc-2026.yaml"323334# --- Money -------------------------------------------------------------------35def test_money_rejects_floats() -> None:36    with pytest.raises(TypeError):37        Money(19.99, "CAD")  # type: ignore[arg-type]38    with pytest.raises(TypeError):39        money("10.00", "CAD").multiply(1.05)  # type: ignore[arg-type]404142def test_money_currency_mismatch() -> None:43    with pytest.raises(CurrencyMismatchError):44        money("1", "CAD") + money("1", "USD")454647def test_rounding_modes_differ_on_ties() -> None:48    half = money("0.125", "CAD")49    assert half.quantized(RoundingMode.HALF_UP).amount == Decimal("0.13")50    assert half.quantized(RoundingMode.HALF_EVEN).amount == Decimal("0.12")515253def test_air_schema_rejects_float_amounts() -> None:54    with pytest.raises(Exception):55        AirDocument.model_validate({56            "events": [{57                "id": "evt_f", "type": "Sale", "date": date(2026, 1, 1),58                "amount": {"amount": 19.99, "currency": "CAD"},59            }]60        })616263# --- ALSL strictness -----------------------------------------------------------64def test_alsl_rejects_uncited_tax_policy(tmp_path: Path) -> None:65    bad = tmp_path / "bad.yaml"66    bad.write_text(67        "alsl_version: \"0.1\"\npolicy_set: bad\nversion: \"1\"\n"68        "policies:\n  - name: mystery_tax\n    kind: tax\n"69        "    when: {jurisdiction: CA-QC}\n"70        "    apply:\n      - {code: GST, rate: \"0.05\"}\n",71        encoding="utf-8",72    )73    with pytest.raises(AlslLoadError, match="cite a source"):74        load_policy_set(bad)757677def test_alsl_rejects_float_rates(tmp_path: Path) -> None:78    bad = tmp_path / "bad.yaml"79    bad.write_text(80        "alsl_version: \"0.1\"\npolicy_set: bad\nversion: \"1\"\n"81        "policies:\n  - name: float_tax\n    kind: tax\n"82        "    source: docs/research/canada-gst-qst.md\n"83        "    when: {jurisdiction: CA-QC}\n"84        "    apply:\n      - {code: GST, rate: 0.05}\n",85        encoding="utf-8",86    )87    with pytest.raises(AlslLoadError, match="float"):88        load_policy_set(bad)899091# --- Diagnostics -----------------------------------------------------------------92def test_unknown_jurisdiction_is_a_precise_error() -> None:93    policies = load_policy_set(POLICY_FILE)94    document = AirDocument.model_validate({95        "events": [{96            "id": "evt_bc", "type": "Sale", "date": date(2026, 1, 1),97            "amount": {"amount": "100.00", "currency": "CAD"},98            "tax": {"jurisdiction": "CA-BC"},99        }]100    })101    with pytest.raises(CompilationError) as exc:102        compile_document(document, policies)103    codes = [d.code for d in exc.value.diagnostics]104    assert "AIR-E400" in codes105    rendered = "\n".join(d.render() for d in exc.value.diagnostics)106    assert "CA-BC" in rendered and "help:" in rendered107108109def test_missing_fx_rate_is_a_precise_error() -> None:110    policies = load_policy_set(POLICY_FILE)111    document = AirDocument.model_validate({112        "events": [{113            "id": "evt_usd", "type": "Sale", "date": date(2026, 1, 1),114            "amount": {"amount": "100.00", "currency": "USD"},115            "tax": {"jurisdiction": "CA-QC", "exempt": True},116        }]117    })118    with pytest.raises(CompilationError) as exc:119        compile_document(document, policies)120    assert any(d.code == "AIR-E500" for d in exc.value.diagnostics)121122123# --- helpers ---------------------------------------------------------------------124def _demo_journal():125    policies = load_policy_set(POLICY_FILE)126    document = AirDocument.model_validate({127        "events": [128            {"id": "evt_own", "type": "OwnerContribution",129             "date": date(2026, 1, 1),130             "amount": {"amount": "10000.00", "currency": "CAD"}},131            {"id": "evt_sale", "type": "Sale", "date": date(2026, 1, 10),132             "amount": {"amount": "1000.00", "currency": "CAD"},133             "tax": {"jurisdiction": "CA-QC"}},134            {"id": "evt_buy", "type": "Purchase", "date": date(2026, 1, 12),135             "amount": {"amount": "400.00", "currency": "CAD"},136             "tax": {"jurisdiction": "CA-QC"}},137        ]138    })139    journal, _ = compile_document(document, policies)140    return journal141142143# --- Ledger (native, hash-chained) --------------------------------------------------144def test_ledger_hash_chain_and_idempotency(tmp_path: Path) -> None:145    journal = _demo_journal()146    ledger = Ledger(path=tmp_path / "ledger.jsonl")147    assert ledger.post_journal(journal, "key-1") == 3148    assert ledger.post_journal(journal, "key-1") == 0  # idempotent replay149    assert ledger.verify_chain()150151    # reload from disk: same entries, chain still valid152    reloaded = Ledger(path=tmp_path / "ledger.jsonl")153    assert len(reloaded.entries) == 3154    assert reloaded.verify_chain()155156    # tampering is detected157    lines = (tmp_path / "ledger.jsonl").read_text().splitlines()158    record = json.loads(lines[0])159    record["lines"][0]["amount"] = "9999.99"160    lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))161    (tmp_path / "ledger.jsonl").write_text("\n".join(lines) + "\n")162    assert not Ledger(path=tmp_path / "ledger.jsonl").verify_chain()163164165def test_ledger_reversal_nets_to_zero() -> None:166    journal = _demo_journal()167    ledger = Ledger()168    ledger.post_journal(journal, "key-1")169    for entry in list(ledger.entries):170        ledger.reverse_entry(entry.id, f"rev:{entry.id}")171    for per_ccy in ledger.balances().values():172        for balance in per_ccy.values():173            assert balance == 0174175176# --- Reporting -------------------------------------------------------------------177def test_reports_balance_and_render() -> None:178    journal = _demo_journal()179    ledger = Ledger()180    ledger.post_journal(journal, "key-1")181182    tb = trial_balance(ledger, "csv")183    total_row = [r for r in tb.splitlines() if r.startswith("TOTAL")][0]184    cells = total_row.split(",")185    assert cells[-2] == cells[-1]  # debits == credits186187    inc = income_statement(ledger, "json")188    data = json.loads(inc)189    net = [r for r in data["rows"] if r["section"] == "NET INCOME"][0]190    assert Decimal(net["amount"]) == Decimal("600.00")  # 1000 revenue - 400 expense191192    bs = balance_sheet(ledger, "markdown")193    lines = [l for l in bs.splitlines() if "TOTAL" in l]194    assets = [l for l in lines if "TOTAL ASSETS" in l][0].split("|")[-2].strip()195    liabeq = [l for l in lines if "TOTAL LIAB." in l][0].split("|")[-2].strip()196    assert assets == liabeq  # Assets = Liabilities + Equity (+ net income)197198199# --- Backends ----------------------------------------------------------------------200def test_csv_backend_post_and_reverse(tmp_path: Path) -> None:201    journal = _demo_journal()202    backend = GenericCsvBackend(tmp_path)203    receipt = backend.post(backend.compile(journal), "abc123")204    content = Path(receipt.reference).read_text()205    assert "je_evt_sale" in content and "1149.75" in content206207    reversal = backend.reverse(receipt)208    rev = Path(reversal.reference).read_text()209    assert "rev_je_evt_sale" in rev and "REVERSAL:" in rev210    # sides swapped: original AR debit becomes credit211    orig_line = [l for l in content.splitlines() if "1149.75" in l][0]212    rev_line = [l for l in rev.splitlines() if "1149.75" in l][0]213    assert "debit" in orig_line and "credit" in rev_line214215216def test_native_backend_roundtrip(tmp_path: Path) -> None:217    journal = _demo_journal()218    backend = NativeLedgerBackend(tmp_path / "books.jsonl")219    receipt = backend.post(backend.compile(journal), "batch-1")220    assert receipt.details["entries_appended"] == "3"221    assert backend.ledger.verify_chain()222