|
| 1 | +"""Differential correctness tests: rustfava (rustledger engine) vs beancount. |
| 2 | +
|
| 3 | +rustfava aims for beancount compatibility, so beancount's own loader is a |
| 4 | +ground-truth *oracle* that the snapshot tests lack — a snapshot only proves the |
| 5 | +output did not change, not that it is correct. These tests load the same ledger |
| 6 | +through both engines and assert the booked results agree, so a booking or |
| 7 | +balance-handling divergence fails loudly instead of being frozen into a |
| 8 | +snapshot. |
| 9 | +
|
| 10 | +This is Layer 1 of the correctness testing plan. Start here when a report shows |
| 11 | +a wrong number: if the booked inventories already differ from beancount, the |
| 12 | +bug is in loading/booking, not in the report layer. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import datetime |
| 18 | +from collections import defaultdict |
| 19 | +from decimal import Decimal |
| 20 | +from pathlib import Path |
| 21 | +from typing import TYPE_CHECKING |
| 22 | + |
| 23 | +import pytest |
| 24 | + |
| 25 | +# beancount is the ground-truth oracle (installed via the `beancount-compat` |
| 26 | +# test extra). Skip cleanly rather than fail if it is somehow absent. |
| 27 | +beancount_loader = pytest.importorskip("beancount.loader") |
| 28 | + |
| 29 | +from rustfava.rustledger import loader as rf_loader # noqa: E402 |
| 30 | +from rustfava.rustledger.backend import get_engine # noqa: E402 |
| 31 | +from rustfava.rustledger.types import directives_from_json # noqa: E402 |
| 32 | +from rustfava.rustledger.types import directives_to_json # noqa: E402 |
| 33 | + |
| 34 | +if TYPE_CHECKING: |
| 35 | + from collections.abc import Iterable |
| 36 | + |
| 37 | +DATA = Path(__file__).parent / "data" |
| 38 | + |
| 39 | +# Fixtures whose booking rustfava must reproduce exactly. These span |
| 40 | +# held-at-cost lots, prices, multiple currencies, a pad/balance pair (example) |
| 41 | +# and a date-boundary case (off-by-one); long-example alone has ~193 cost lots. |
| 42 | +LEDGERS = ["example", "long-example", "query-example", "off-by-one"] |
| 43 | + |
| 44 | +# Type of `cost` normalized to beancount's 4-tuple identity — deliberately |
| 45 | +# dropping rustfava's extra ``number_total`` field so two engines' economically |
| 46 | +# equal lots compare equal here (that extra field leaking into lot identity is |
| 47 | +# a separate defect; this oracle should not be sensitive to it). |
| 48 | +CostKey = tuple[Decimal | None, str | None, object, str | None] |
| 49 | +AccountInventory = dict[tuple[str | None, CostKey | None], Decimal] |
| 50 | + |
| 51 | + |
| 52 | +def _cost_key(cost: object) -> CostKey | None: |
| 53 | + if cost is None: |
| 54 | + return None |
| 55 | + number = getattr(cost, "number", None) |
| 56 | + return ( |
| 57 | + Decimal(number) if number is not None else None, |
| 58 | + getattr(cost, "currency", None), |
| 59 | + getattr(cost, "date", None), |
| 60 | + getattr(cost, "label", None), |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +def _account_inventories( |
| 65 | + entries: Iterable[object], |
| 66 | +) -> dict[str, AccountInventory]: |
| 67 | + """Fold every posting into ``{account: {(currency, cost): Decimal}}``. |
| 68 | +
|
| 69 | + Works for both engines because rustfava registers its directive/posting |
| 70 | + types against beancount's ABCs, so the ``postings``/``units``/``cost`` |
| 71 | + attribute shape is identical. |
| 72 | + """ |
| 73 | + inv: dict[str, AccountInventory] = defaultdict( |
| 74 | + lambda: defaultdict(Decimal) |
| 75 | + ) |
| 76 | + for entry in entries: |
| 77 | + for posting in getattr(entry, "postings", []): |
| 78 | + units = posting.units |
| 79 | + if units is None or units.number is None: |
| 80 | + continue |
| 81 | + key = (units.currency, _cost_key(posting.cost)) |
| 82 | + inv[posting.account][key] += Decimal(units.number) |
| 83 | + # Drop keys/accounts that net to exactly zero. |
| 84 | + return { |
| 85 | + account: {k: v for k, v in lots.items() if v != 0} |
| 86 | + for account, lots in inv.items() |
| 87 | + if any(v != 0 for v in lots.values()) |
| 88 | + } |
| 89 | + |
| 90 | + |
| 91 | +@pytest.mark.parametrize("ledger", LEDGERS) |
| 92 | +def test_booked_inventories_match_beancount(ledger: str) -> None: |
| 93 | + """Per-account booked inventories must equal beancount's, to the cent.""" |
| 94 | + path = str(DATA / f"{ledger}.beancount") |
| 95 | + bc_entries, _bc_errors, _ = beancount_loader.load_file(path) |
| 96 | + rf_entries, _rf_errors, _ = rf_loader.load_uncached(path) |
| 97 | + |
| 98 | + bc_inv = _account_inventories(bc_entries) |
| 99 | + rf_inv = _account_inventories(rf_entries) |
| 100 | + |
| 101 | + # Compare per account for a readable diff on failure. |
| 102 | + assert set(rf_inv) == set(bc_inv), ( |
| 103 | + f"account set differs: only-rustfava={set(rf_inv) - set(bc_inv)}, " |
| 104 | + f"only-beancount={set(bc_inv) - set(rf_inv)}" |
| 105 | + ) |
| 106 | + for account in sorted(bc_inv): |
| 107 | + assert rf_inv[account] == bc_inv[account], ( |
| 108 | + f"inventory mismatch for {account}: " |
| 109 | + f"rustfava={rf_inv[account]} beancount={bc_inv[account]}" |
| 110 | + ) |
| 111 | + |
| 112 | + |
| 113 | +def _balance_dirs(entries: Iterable[object]) -> list[object]: |
| 114 | + return [e for e in entries if type(e).__name__ in {"Balance", "RLBalance"}] |
| 115 | + |
| 116 | + |
| 117 | +def test_failing_balance_assertion_is_surfaced() -> None: |
| 118 | + """A failing `balance` must produce an error AND a non-zero diff. |
| 119 | +
|
| 120 | + Regression for rustledger#1663 / the C1 gap: `load()` (the display path) |
| 121 | + previously reported no error for a failing assertion, so the journal showed |
| 122 | + it as passed. rustledger 3.1.0 (v0.18.0) reports balance failures on load |
| 123 | + and carries `diff` on the directive; the journal's red/green keys off a |
| 124 | + present ``diff_amount``. |
| 125 | + """ |
| 126 | + src = ( |
| 127 | + "2024-01-01 open Assets:Cash USD\n" |
| 128 | + "2024-01-01 open Expenses:X USD\n" |
| 129 | + '2024-01-02 * "t"\n' |
| 130 | + " Expenses:X 5 USD\n" |
| 131 | + " Assets:Cash\n" |
| 132 | + "2024-01-03 balance Assets:Cash 999 USD\n" |
| 133 | + ) |
| 134 | + entries, errors, _ = rf_loader.load_string(src, "<differential>") |
| 135 | + assert any( |
| 136 | + "balance" in str(getattr(e, "message", e)).lower() for e in errors |
| 137 | + ), "a failing balance assertion produced no error" |
| 138 | + (bal,) = _balance_dirs(entries) |
| 139 | + # Real balance is -5, asserted 999 -> non-zero diff -> renders as failed. |
| 140 | + assert bal.diff_amount is not None |
| 141 | + assert bal.diff_amount.number != 0 |
| 142 | + |
| 143 | + |
| 144 | +def test_passing_balance_assertion_is_green() -> None: |
| 145 | + """A passing `balance` must be silent: no error, and no diff_amount. |
| 146 | +
|
| 147 | + Guards the zero-diff mapping — the engine sends `diff = 0` on a passing |
| 148 | + assertion, and carrying that through would mark every passing balance |
| 149 | + ``diff_amount`` (i.e. failed) in the journal. |
| 150 | + """ |
| 151 | + src = ( |
| 152 | + "2024-01-01 open Assets:Cash USD\n" |
| 153 | + "2024-01-01 open Expenses:X USD\n" |
| 154 | + '2024-01-02 * "t"\n' |
| 155 | + " Expenses:X 5 USD\n" |
| 156 | + " Assets:Cash\n" |
| 157 | + "2024-01-03 balance Assets:Cash -5 USD\n" |
| 158 | + ) |
| 159 | + entries, errors, _ = rf_loader.load_string(src, "<differential>") |
| 160 | + assert errors == [] |
| 161 | + (bal,) = _balance_dirs(entries) |
| 162 | + assert bal.diff_amount is None |
| 163 | + |
| 164 | + |
| 165 | +def test_clamped_totals_match_beancount_balance_at_cutoff() -> None: |
| 166 | + """The time-filter (engine clamp) opening balances must reconstruct the |
| 167 | + correct closing totals — the path that rustledger#1656 broke. |
| 168 | +
|
| 169 | + Clamping to ``[begin, end)`` yields synthesized opening balances plus |
| 170 | + in-window postings; summed per account that equals the ledger's balance as |
| 171 | + of ``end`` (every posting dated before ``end``). Cross-check that against |
| 172 | + beancount, per account and per (currency, cost) lot. The synthesized |
| 173 | + ``Equity:Opening-Balances`` contra is clamp-specific, so exclude it. |
| 174 | + """ |
| 175 | + path = str(DATA / "long-example.beancount") |
| 176 | + begin, end = "2014-01-01", "2015-01-01" |
| 177 | + cutoff = datetime.date(2015, 1, 1) |
| 178 | + opening = "Equity:Opening-Balances" |
| 179 | + |
| 180 | + rf_entries, _, _ = rf_loader.load_uncached(path) |
| 181 | + clamped = directives_from_json( |
| 182 | + get_engine().clamp_entries( |
| 183 | + directives_to_json(list(rf_entries)), begin, end |
| 184 | + )["entries"] |
| 185 | + ) |
| 186 | + rf_inv = { |
| 187 | + a: v |
| 188 | + for a, v in _account_inventories(clamped).items() |
| 189 | + if a != opening |
| 190 | + } |
| 191 | + |
| 192 | + bc_entries, _, _ = beancount_loader.load_file(path) |
| 193 | + before_cutoff = [ |
| 194 | + e |
| 195 | + for e in bc_entries |
| 196 | + if getattr(e, "date", cutoff) < cutoff |
| 197 | + ] |
| 198 | + bc_inv = { |
| 199 | + a: v |
| 200 | + for a, v in _account_inventories(before_cutoff).items() |
| 201 | + if a != opening |
| 202 | + } |
| 203 | + |
| 204 | + assert set(rf_inv) == set(bc_inv) |
| 205 | + for account in sorted(bc_inv): |
| 206 | + assert rf_inv[account] == bc_inv[account], ( |
| 207 | + f"clamped total mismatch for {account}: " |
| 208 | + f"rustfava={rf_inv[account]} beancount={bc_inv[account]}" |
| 209 | + ) |
0 commit comments