- Add CURRENCY_DECIMALS dict (JPY=0, BHD/KWD/OMR=3, rest=2) and DEFAULT_CURRENCY_DECIMALS=2
- Add CurrencyConverter.format_money() staticmethod with ROUND_HALF_UP; symbol-prefix for known, code-prefix for unknown
- Replace 14 :,.2f literals across services.py / info.py / config_cmd.py with format_money() calls
- Refactor 2 i18n templates (transaction_add.ok, recurring_add.ok): {amount:.2f} -> {amount_str}; callers pass pre-formatted string
- New _account_currency() helper for transaction currency lookup
- Extend test_currency.py: 8 new tests (JPY/BHD/USD/CHF/negative/zero/float-int/lowercase)
- 75/75 tests pass; 0 :,.2f literals remain in infrastructure/cli/
113 lines
4.3 KiB
Python
113 lines
4.3 KiB
Python
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from domain import CurrencyConverter, CurrencyError, ExchangeRate
|
|
|
|
|
|
class TestExchangeRate:
|
|
def test_to_dict_roundtrip(self):
|
|
rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)
|
|
d = rate.model_dump(mode="json")
|
|
r2 = ExchangeRate.model_validate(d)
|
|
assert r2.from_currency == "USD"
|
|
assert r2.to_currency == "RUB"
|
|
assert r2.rate == 80.0
|
|
|
|
def test_defaults(self):
|
|
r = ExchangeRate()
|
|
assert r.from_currency == "USD"
|
|
assert r.to_currency == "RUB"
|
|
assert r.rate == 80.0
|
|
|
|
|
|
class TestCurrencyConverter:
|
|
def test_convert_usd_to_rub(self, sample_converter):
|
|
result = sample_converter.convert(100, "USD", "RUB")
|
|
assert result == 8000.0
|
|
|
|
def test_convert_rub_to_usd(self, sample_converter):
|
|
result = sample_converter.convert(8000, "RUB", "USD")
|
|
assert result == 100.0
|
|
|
|
def test_same_currency(self, sample_converter):
|
|
result = sample_converter.convert(500, "USD", "USD")
|
|
assert result == 500.0
|
|
|
|
def test_unknown_pair(self):
|
|
converter = CurrencyConverter()
|
|
with pytest.raises(CurrencyError):
|
|
converter.convert(100, "USD", "RUB")
|
|
|
|
def test_negative_rate(self):
|
|
converter = CurrencyConverter()
|
|
with pytest.raises(CurrencyError):
|
|
converter.set_rate("USD", "RUB", -1)
|
|
|
|
def test_zero_rate(self):
|
|
converter = CurrencyConverter()
|
|
with pytest.raises(CurrencyError):
|
|
converter.set_rate("USD", "RUB", 0)
|
|
|
|
def test_inverse_auto(self):
|
|
rate = ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0)
|
|
converter = CurrencyConverter([rate])
|
|
assert converter.convert(900, "RUB", "EUR") == 10.0
|
|
|
|
def test_get_symbol(self, sample_converter):
|
|
assert sample_converter.get_symbol("USD") == "$"
|
|
assert sample_converter.get_symbol("RUB") == "₽"
|
|
assert sample_converter.get_symbol("XYZ") == "XYZ"
|
|
|
|
def test_with_defaults(self):
|
|
converter = CurrencyConverter.with_defaults()
|
|
assert converter.convert(10, "USD", "RUB") == 800.0
|
|
|
|
def test_multiple_rates(self):
|
|
rates = [
|
|
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
|
ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0),
|
|
]
|
|
converter = CurrencyConverter(rates)
|
|
assert converter.convert(10, "USD", "RUB") == 800.0
|
|
assert converter.convert(10, "EUR", "RUB") == 900.0
|
|
assert converter.convert(900, "RUB", "EUR") == 10.0
|
|
|
|
|
|
class TestFormatMoney:
|
|
"""ISO 4217-aware display formatting. Rounding mode: ROUND_HALF_UP."""
|
|
|
|
def test_jpy_zero_decimals(self):
|
|
c = CurrencyConverter()
|
|
assert c.format_money(Decimal("1234.567"), "JPY") == "¥1,235"
|
|
|
|
def test_usd_two_decimals(self, sample_converter):
|
|
assert sample_converter.format_money(Decimal("1234.567"), "USD") == "$1,234.57"
|
|
|
|
def test_bhd_three_decimals(self):
|
|
# BHD has 3 minor units and is NOT in CURRENCY_SYMBOLS — code prefix with space.
|
|
c = CurrencyConverter()
|
|
assert c.format_money(Decimal("1.234"), "BHD") == "BHD 1.234"
|
|
|
|
def test_chf_unknown_code_two_decimals(self):
|
|
# Unknown code falls back to DEFAULT_CURRENCY_DECIMALS=2, layout is code prefix.
|
|
c = CurrencyConverter()
|
|
assert c.format_money(Decimal("1.234"), "CHF") == "CHF 1.23"
|
|
|
|
def test_negative_amount(self, sample_converter):
|
|
# Sign must be rendered before the symbol, not embedded in the number.
|
|
assert sample_converter.format_money(Decimal("-1200.00"), "RUB") == "-₽1,200.00"
|
|
|
|
def test_zero_amount(self, sample_converter):
|
|
assert sample_converter.format_money(Decimal("0"), "JPY") == "¥0"
|
|
assert sample_converter.format_money(Decimal("0"), "USD") == "$0.00"
|
|
|
|
def test_accepts_float_and_int(self, sample_converter):
|
|
# Defensive: callers from CLI pass float — format_money must coerce.
|
|
assert sample_converter.format_money(1500.5, "USD") == "$1,500.50"
|
|
assert sample_converter.format_money(1500, "JPY") == "¥1,500"
|
|
|
|
def test_case_insensitive_currency_code(self, sample_converter):
|
|
# Defensive: lower-case codes are accepted; storage stays upper-case.
|
|
assert sample_converter.format_money(Decimal("100"), "usd") == "$100.00"
|