|
| 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