tests/test_money.py
Rama main · réplica de lectura del repositorio original.
"""Money renders as money — with its currency, and with the sign in front.
The bug this file exists for: templates wrote a literal `$` in front of
`{{ x | money }}`, so every negative figure came out as **`$-4.00`** and the
symbol sat outside the `<span class="m">` that the idle overlay blurs. Both are
the same mistake — treating the symbol as decoration rather than part of the
value — and both are fixed by one place turning an amount into text.
"""
from __future__ import annotations
import re
from decimal import Decimal
from pathlib import Path
import pytest
from app import columns, money
TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "templates"
# --------------------------------------------------------------------------- #
# The formatting itself
# --------------------------------------------------------------------------- #
def test_the_sign_comes_before_the_symbol():
"""`-$4.00`, never `$-4.00`. The whole reason a literal prefix was wrong."""
assert money.text(Decimal("-4")) == "-$4.00"
def test_a_positive_amount_has_no_sign():
assert money.text(Decimal("1234.5")) == "$1,234.50"
def test_thousands_are_grouped_and_cents_always_shown():
assert money.text(Decimal("1234567")) == "$1,234,567.00"
def test_nothing_renders_as_a_dash_not_a_zero():
"""A missing price and a price of zero are different facts."""
assert money.text(None) == "—"
assert money.plain(None) == "—"
assert money.text(Decimal("0")) == "$0.00"
@pytest.mark.parametrize("code,expected", [
("AUD", "$"), ("USD", "$"), ("EUR", "€"), ("GBP", "£"), ("JPY", "¥"),
])
def test_known_currencies_use_their_symbol(code, expected):
assert money.symbol(code) == expected
def test_an_unknown_currency_renders_as_its_code():
"""Honest beats invented: guessing a symbol for a currency nobody listed
would be worse than showing the code."""
assert money.symbol("KRW") == "KRW"
assert money.text(Decimal("10"), "KRW") == "KRW10.00"
def test_currencies_sharing_a_symbol_are_flagged():
"""T28b shows a second currency beside the first, and `$1,000 ($1,425)`
says nothing about which is which. These are the ones needing their code."""
assert money.is_shared("AUD") is True
assert money.is_shared("USD") is True
assert money.is_shared("EUR") is False
def test_no_currency_means_the_reporting_one():
assert money.symbol() == money.symbol(money.REPORTING)
# --------------------------------------------------------------------------- #
# Where the currency is named
# --------------------------------------------------------------------------- #
def test_a_reporting_column_names_the_reporting_currency():
assert columns.BY_KEY["value_reporting"].heading() == f"Value ({money.REPORTING})"
def test_a_native_column_names_the_portfolio_s_currency_when_there_is_one():
assert columns.BY_KEY["price"].heading("USD") == "Price (USD)"
def test_a_native_column_says_native_when_the_portfolio_holds_several():
"""It genuinely cannot name one, and saying `(AUD)` would be a lie on
half the rows."""
assert columns.BY_KEY["price"].heading(None) == "Price (native)"
def test_a_non_money_column_gets_no_currency():
assert columns.BY_KEY["units"].heading("AUD") == "Units"
assert columns.BY_KEY["gain_pct"].heading("AUD") == "Unrealised gain %"
def test_the_two_cost_columns_are_told_apart_only_by_their_currency():
"""`cost` and `cost_reporting` share the label "Cost base". If `heading` ever stopped
adding the currency, the chooser would show two identical checkboxes and
picking one would be a coin toss."""
native = columns.BY_KEY["cost"].heading("USD")
reporting = columns.BY_KEY["cost_reporting"].heading("USD")
assert native != reporting, "the two Cost columns are indistinguishable"
assert "USD" in native
assert money.REPORTING in reporting
def test_a_single_currency_table_does_not_repeat_the_currency_per_column():
"""Seven headers each saying "(AUD)" is the same noise as a `$` in every
cell, moved up one row. The table names it once instead."""
assert columns.BY_KEY["value_reporting"].heading("AUD", mark=False) == "Value"
assert columns.BY_KEY["price"].heading("AUD", mark=False) == "Price"
def test_marking_is_off_only_when_everything_really_is_one_currency():
assert columns.one_currency_everywhere(money.REPORTING) is True
# A portfolio of US holdings reported in AUD: native and reporting columns
# show DIFFERENT currencies, so both must be named.
assert columns.one_currency_everywhere("USD") is False
# Mixed holdings: there is no single native currency at all.
assert columns.one_currency_everywhere(None) is False
def test_every_money_column_declares_which_currency_it_is_in():
"""A money column with no `currency` renders a bare number with nothing
saying what it is — which is the defect this whole task is about."""
missing = [c.key for c in columns.COLUMNS
if c.render in ("money", "gain") and c.currency is None]
assert not missing, (
f"money columns with no declared currency: {missing}. Set "
"currency='native' or 'reporting' so the header can name it.")
# --------------------------------------------------------------------------- #
# The habit that caused it
# --------------------------------------------------------------------------- #
def test_no_template_writes_a_currency_symbol_next_to_the_money_filter():
"""The defect, pinned.
`${{ x | money }}` put the symbol outside the blur span and in front of the
minus sign. Use `| cash` when a figure needs its symbol — a headline number
with no column header to carry it — and `| money` in a table cell whose
header names the currency.
"""
offenders = []
for path in sorted(TEMPLATES.glob("*.html")):
for found in re.finditer(r"[$€£¥]\s*\{\{[^}]*\|\s*(money|cash)", path.read_text(encoding="utf-8")):
offenders.append(f"{path.name}: {found.group(0)!r}")
assert not offenders, (
"a currency symbol is written next to a money filter — the filter puts "
"it there itself, in the right place relative to the minus sign and "
"inside the privacy blur:\n " + "\n ".join(offenders))
# --------------------------------------------------------------------------- #
# Parsing a figure somebody typed or a file carried
# --------------------------------------------------------------------------- #
def test_a_figure_that_fits_parses_with_its_digits_intact():
from app.models import Trade
assert money.parse(" 12.50 ", Trade.unit_price) == Decimal("12.50")
assert money.parse("0", Trade.unit_price) == Decimal("0") # a free parcel
assert money.parse("-3", Trade.unit_price) == Decimal("-3") # sign is the caller's
@pytest.mark.parametrize("text", ["NaN", "Infinity", "-Infinity", "sNaN", "abc", ""])
def test_a_figure_that_is_not_a_finite_number_is_refused(text):
from app.models import Trade
with pytest.raises(money.FigureError, match="is not a number"):
money.parse(text, Trade.quantity)
@pytest.mark.parametrize("text", ["1E+999999", "1E+309", "1E+20", "1000000000000"])
def test_a_figure_too_big_for_its_column_is_refused(text):
"""Finite, so `Decimal` accepts it. SQLite stores a float and reads 1E+999999
back as Infinity; Postgres refuses it with an overflow."""
from app.models import Trade
with pytest.raises(money.FigureError, match="is too large"):
money.parse(text, Trade.quantity)
def test_the_limit_is_taken_from_the_column_so_it_cannot_drift_from_the_schema():
from app.models import Trade
# Numeric(20, 8) holds twelve digits before the point; Numeric(10, 2) eight.
assert money.limit(Trade.quantity) == Decimal(10) ** 12
assert money.limit(Trade.brokerage) == Decimal(10) ** 8
assert money.parse("999999999999.99999999", Trade.quantity)
with pytest.raises(money.FigureError):
money.parse("100000000", Trade.brokerage)
def test_the_message_names_the_field_and_the_value():
from app.models import Trade
with pytest.raises(money.FigureError) as caught:
money.parse("NaN", Trade.quantity, "Units")
assert str(caught.value) == "Units: 'NaN' is not a number"
assert isinstance(caught.value, ValueError) # an existing `except ValueError` works
def test_a_long_value_is_cut_down_in_the_message():
"""Forms carry the message in a redirect's query string; a pasted page of
text would make that URL too long for the proxy."""
from app.models import Trade
with pytest.raises(money.FigureError) as caught:
money.parse("x" * 5000, Trade.quantity, "Units")
assert str(caught.value) == "Units: '" + "x" * 20 + "…' is not a number"
assert len(str(caught.value)) < 60
def test_a_short_value_is_quoted_whole():
from app.models import Trade
with pytest.raises(money.FigureError) as caught:
money.parse("x" * 20, Trade.quantity)
assert "…" not in str(caught.value)
def test_a_value_that_rounds_up_to_the_limit_is_refused():
"""Under 10**12 as typed, but the database rounds to eight places, and the
result is 10**12, which Postgres refuses with an overflow."""
from app.models import Trade
with pytest.raises(money.FigureError, match="is too large"):
money.parse("999999999999.999999999", Trade.quantity)
with pytest.raises(money.FigureError, match="is too large"):
money.parse("999999999999.999999995", Trade.quantity) # half rounds up
assert money.parse("999999999999.999999994", Trade.quantity) # rounds down: fits
assert money.parse("999999999999.99999999", Trade.quantity) # exactly the largest