Skip to content

Commit 7eba21d

Browse files
committed
test(precision): characterize and guard the Decimal->float JSON boundary
The JSON API serializes Decimals via float() (core/charts.py), so exact ledger values cross the wire as float64. The frontend renders at display precision, so typical currency values are unaffected in the UI — but exports, raw API consumers, and high-precision values see the rounding. Layer 2 of the correctness plan: pin the boundary so it can't regress and a fix is measurable. - test_typical_values_are_lossless: currency + 8dp crypto + bounded magnitudes round-trip exactly (guards the common case). - test_high_precision_is_lost: xfail(strict) documenting that values beyond ~16 significant digits are rounded on the wire; flips green when numbers are emitted exactly (needs an exact JSON encoder + frontend decimal support, which also regenerates all snapshots — that epic, not an isolated wire change). - test_api_numbers_are_json_numbers_not_strings: pins the current wire contract so any format switch is deliberate.
1 parent a343b17 commit 7eba21d

1 file changed

Lines changed: 92 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Number-precision boundary characterization (Layer 2 of correctness plan).
2+
3+
The JSON API serializes every ``Decimal`` via ``float()``
4+
(``core/charts.py:_json_default``), so exact ledger Decimals cross the wire as
5+
IEEE-754 float64. The frontend has no decimal type and renders numbers through
6+
``Intl.NumberFormat`` at display precision, so **typical currency values are
7+
unaffected in the rendered UI** — but the float rounding is visible to raw JSON
8+
API consumers, CSV/Excel export (``util/excel.py`` also does ``float()``), and
9+
column sorting, and it silently corrupts high-precision values.
10+
11+
These tests pin the boundary so it can't regress and so a future fix is
12+
measurable:
13+
14+
* ``test_typical_values_are_lossless`` — currency and reasonable crypto
15+
magnitudes must survive the wire exactly (they do today: float64 holds ~15-16
16+
significant digits). This guards the common case.
17+
* ``test_high_precision_is_lost`` — documents, as an ``xfail``, that values
18+
beyond float64's precision are rounded on the wire. It flips to passing once
19+
numbers are emitted exactly, which requires an exact JSON encoder (e.g.
20+
``simplejson`` with ``use_decimal``) **and** frontend decimal support — the
21+
wire change alone also regenerates every snapshot, so it belongs with that
22+
epic, not in isolation.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from decimal import Decimal
28+
29+
import pytest
30+
31+
from rustfava.core.charts import dumps
32+
from rustfava.core.charts import loads
33+
34+
35+
def _round_trip(value: Decimal) -> Decimal:
36+
"""Serialize a Decimal through the real API provider and read it back."""
37+
wire = dumps({"n": value})
38+
return Decimal(str(loads(wire)["n"]))
39+
40+
41+
# Values a real ledger produces: 2dp fiat, 8dp crypto, large-but-bounded share
42+
# counts, small residues. All within float64's ~15-16 significant digits.
43+
LOSSLESS = [
44+
"0",
45+
"100.00",
46+
"-1234.56",
47+
"0.00801",
48+
"1234567.89",
49+
"0.12345678", # 8dp crypto
50+
"12345678.12345678", # 16 significant digits
51+
"-9876543.21",
52+
]
53+
54+
55+
@pytest.mark.parametrize("literal", LOSSLESS)
56+
def test_typical_values_are_lossless(literal: str) -> None:
57+
"""Currency and reasonable-precision values must cross the wire exactly."""
58+
value = Decimal(literal)
59+
assert _round_trip(value) == value
60+
61+
62+
# Values beyond float64 precision: a division result (conversions / unrealized
63+
# %), a large magnitude with decimals, and a high-precision large number.
64+
LOSSY = [
65+
"88.571428571428571428571429", # 26 significant digits (e.g. 620/7)
66+
"123456789012.123456", # 18 significant digits
67+
"9999999999999999.01", # rounds catastrophically to 1e16
68+
]
69+
70+
71+
@pytest.mark.parametrize("literal", LOSSY)
72+
@pytest.mark.xfail(
73+
reason="Decimal->float64 at the JSON boundary (core/charts.py) rounds "
74+
"beyond ~16 significant digits. Fixing end-to-end needs an exact JSON "
75+
"encoder plus frontend decimal support (and regenerates all snapshots); "
76+
"see the correctness plan. Flips green when numbers are emitted exactly.",
77+
strict=True,
78+
)
79+
def test_high_precision_is_lost(literal: str) -> None:
80+
"""High-precision values are currently rounded on the wire (documented)."""
81+
value = Decimal(literal)
82+
assert _round_trip(value) == value
83+
84+
85+
def test_api_numbers_are_json_numbers_not_strings() -> None:
86+
"""Pin the current wire contract: report numbers serialize as JSON numbers.
87+
88+
A deliberate switch to exact string/tagged encoding must update this test,
89+
so the wire-format change can't happen silently.
90+
"""
91+
wire = dumps({"n": Decimal("100.00")})
92+
assert '"n":100' in wire # a bare JSON number, not "100.00" quoted

0 commit comments

Comments
 (0)