Skip to content

Commit 4b12925

Browse files
authored
test(differential): stress corpus — @@ prices, many lots, pad+balance (Layer 4) (#213)
* test(differential): stress corpus — @@ prices, many lots, pad+balance (Layer 4) Adds hand-authored fixtures for constructs absent from example/long-example/ query-example/off-by-one, and feeds them through the beancount differential oracle plus explicit hand-verified assertions: - stress-total-price: `@@` total-price posting (per-unit = 10/7); no fixture had `@@` before. rustfava matches beancount; price hand-checked to 1.4286. - stress-many-lots: two lots of one commodity + a partial reduction of the cheaper lot -> 5 HOOL {50} + 10 HOOL {60}, booking matches beancount and the exact lots are asserted. - stress-pad-balance: pad->balance fill then a passing balance; both engines agree and the assertions pass (diff_amount None). All three currently book identically to beancount (0 mismatches), so this locks in that parity against future engine bumps. * test(differential): move stress fixtures out of tests/data (fix CI) Putting stress-*.beancount in tests/data/ made the ingest directory walk pick them up, churning the test_api_imports snapshot (and, because that test then failed before exercising the extract endpoint, dropping coverage below 100%). Relocate them to tests/ledgers/ (not an import dir) and resolve fixture paths via a helper. No snapshot change; behaviour identical.
1 parent 8d510e8 commit 4b12925

4 files changed

Lines changed: 115 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
; Multiple held-at-cost lots of one commodity + a partial FIFO-style reduction
2+
2020-01-01 open Assets:Stock
3+
2020-01-01 open Assets:Cash
4+
2020-01-01 open Income:Gains
5+
6+
2020-02-01 * "buy lot at 50"
7+
Assets:Stock 10 HOOL {50 USD}
8+
Assets:Cash -500 USD
9+
10+
2020-03-01 * "buy lot at 60"
11+
Assets:Stock 10 HOOL {60 USD}
12+
Assets:Cash -600 USD
13+
14+
2020-04-01 * "sell 5 from the 50 lot at 60, realizing a gain"
15+
Assets:Stock -5 HOOL {50 USD}
16+
Assets:Cash 300 USD
17+
Income:Gains
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
; pad -> balance resolution, then a later passing balance
2+
2020-01-01 open Assets:Cash USD
3+
2020-01-01 open Equity:Opening-Balances
4+
5+
2020-01-05 pad Assets:Cash Equity:Opening-Balances
6+
2020-01-10 balance Assets:Cash 500 USD
7+
8+
2020-02-01 * "spend"
9+
Assets:Cash -100 USD
10+
Equity:Opening-Balances
11+
12+
2020-02-10 balance Assets:Cash 400 USD
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
; @@ total-price postings (per-unit = total / units); absent from other fixtures
2+
option "operating_currency" "USD"
3+
4+
2020-01-01 open Assets:USD USD
5+
2020-01-01 open Assets:EUR EUR
6+
2020-01-01 open Income:Trade
7+
8+
2020-02-01 * "convert 7 USD at a total price of 10 EUR"
9+
Assets:USD -7 USD @@ 10 EUR
10+
Assets:EUR 10 EUR
11+
Income:Trade

tests/test_differential_beancount.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,30 @@
3636
from collections.abc import Iterable
3737

3838
DATA = Path(__file__).parent / "data"
39+
# Hand-authored differential fixtures live outside tests/data/ so they are not
40+
# picked up by the ingest directory walk (test_api_imports snapshots that dir).
41+
LEDGERS_DIR = Path(__file__).parent / "ledgers"
42+
43+
44+
def _ledger_path(name: str) -> str:
45+
"""Resolve a fixture to tests/ledgers/ if present, else tests/data/."""
46+
stress = LEDGERS_DIR / f"{name}.beancount"
47+
return str(stress if stress.exists() else DATA / f"{name}.beancount")
3948

4049
# Fixtures whose booking rustfava must reproduce exactly. These span
4150
# held-at-cost lots, prices, multiple currencies, a pad/balance pair (example)
4251
# and a date-boundary case (off-by-one); long-example alone has ~193 cost lots.
43-
LEDGERS = ["example", "long-example", "query-example", "off-by-one"]
52+
# The stress-* fixtures add constructs the others lack: `@@` total prices,
53+
# multiple lots of one commodity with a partial reduction, and pad->balance.
54+
LEDGERS = [
55+
"example",
56+
"long-example",
57+
"query-example",
58+
"off-by-one",
59+
"stress-total-price",
60+
"stress-many-lots",
61+
"stress-pad-balance",
62+
]
4463

4564
# Type of `cost` normalized to beancount's 4-tuple identity — deliberately
4665
# dropping rustfava's extra ``number_total`` field so two engines' economically
@@ -92,7 +111,7 @@ def _account_inventories(
92111
@pytest.mark.parametrize("ledger", LEDGERS)
93112
def test_booked_inventories_match_beancount(ledger: str) -> None:
94113
"""Per-account booked inventories must equal beancount's, to the cent."""
95-
path = str(DATA / f"{ledger}.beancount")
114+
path = _ledger_path(ledger)
96115
bc_entries, _bc_errors, _ = beancount_loader.load_file(path)
97116
rf_entries, _rf_errors, _ = rf_loader.load_uncached(path)
98117

@@ -208,3 +227,57 @@ def test_clamped_totals_match_beancount_balance_at_cutoff() -> None:
208227
f"clamped total mismatch for {account}: "
209228
f"rustfava={rf_inv[account]} beancount={bc_inv[account]}"
210229
)
230+
231+
232+
def test_stress_fixtures_hand_verified() -> None:
233+
"""Explicit expected numbers for the stress fixtures.
234+
235+
The differential tests above already cross-check these against beancount;
236+
this pins the values by hand as a second, oracle-independent check of the
237+
constructs the fixtures were added for.
238+
"""
239+
d = datetime.date
240+
241+
# @@ total price: A drains 7 USD, B gains 10 EUR; the sold posting's price
242+
# is the per-unit 10/7, and no cost is created.
243+
entries, errors, _ = rf_loader.load_uncached(
244+
str(LEDGERS_DIR / "stress-total-price.beancount")
245+
)
246+
assert not errors
247+
inv = _account_inventories(entries)
248+
assert inv["Assets:USD"] == {("USD", None): Decimal(-7)}
249+
assert inv["Assets:EUR"] == {("EUR", None): Decimal(10)}
250+
(usd_posting,) = [
251+
p
252+
for e in entries
253+
for p in getattr(e, "postings", [])
254+
if getattr(p, "account", "") == "Assets:USD"
255+
]
256+
assert usd_posting.cost is None
257+
assert usd_posting.price is not None
258+
# per-unit = 10 EUR / 7 USD = 1.4286 (to 4dp); the engine keeps full
259+
# precision, so compare at a sane scale rather than bit-for-bit.
260+
assert round(usd_posting.price.number, 4) == Decimal("1.4286")
261+
262+
# Many lots: after selling 5 of the 50-lot, 5 HOOL @ {50} + 10 HOOL @ {60}.
263+
entries, errors, _ = rf_loader.load_uncached(
264+
str(LEDGERS_DIR / "stress-many-lots.beancount")
265+
)
266+
assert not errors
267+
stock = _account_inventories(entries)["Assets:Stock"]
268+
assert stock == {
269+
("HOOL", (Decimal(50), "USD", d(2020, 2, 1), None)): Decimal(5),
270+
("HOOL", (Decimal(60), "USD", d(2020, 3, 1), None)): Decimal(10),
271+
}
272+
273+
# pad -> balance: the pad fills Cash to the asserted 500, spend leaves 400,
274+
# and both balance assertions pass (no errors, diff_amount None).
275+
entries, errors, _ = rf_loader.load_uncached(
276+
str(LEDGERS_DIR / "stress-pad-balance.beancount")
277+
)
278+
assert not errors
279+
assert _account_inventories(entries)["Assets:Cash"] == {
280+
("USD", None): Decimal(400)
281+
}
282+
for bal in _balance_dirs(entries):
283+
assert bal.diff_amount is None

0 commit comments

Comments
 (0)