Ctrl K
ComunidadGuías…

tests/test_money.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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