401 lines
13 KiB
Python
401 lines
13 KiB
Python
"""test_reports_balance - Unit tests for reports.core.Balance"""
|
|
# Copyright © 2020 Brett Smith
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import itertools
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from . import testutil
|
|
|
|
import babel.numbers
|
|
|
|
from conservancy_beancount.reports import core
|
|
|
|
DEFAULT_STRINGS = [
|
|
({}, "Zero balance"),
|
|
({'JPY': 0, 'BRL': 0}, "Zero balance"),
|
|
({'USD': '20.00'}, "20.00 USD"),
|
|
({'EUR': '50.00', 'GBP': '80.00'}, "80.00 GBP, 50.00 EUR"),
|
|
({'JPY': '-5500.00', 'BRL': '-8500.00'}, "-8,500.00 BRL, -5,500 JPY"),
|
|
({'USD': 10, 'EUR': '.00015'}, "10.00 USD"),
|
|
({'JPY': '-.00015'}, "Zero balance"),
|
|
]
|
|
|
|
TOLERANCES = [Decimal(n) for n in ['.1', '.01', '.001', 0]]
|
|
|
|
def amounts_from_map(currency_map):
|
|
for code, number in currency_map.items():
|
|
yield testutil.Amount(number, code)
|
|
|
|
def test_empty_balance():
|
|
balance = core.Balance()
|
|
assert not balance
|
|
assert len(balance) == 0
|
|
assert balance.is_zero()
|
|
with pytest.raises(KeyError):
|
|
balance['USD']
|
|
|
|
@pytest.mark.parametrize('currencies', [
|
|
'USD',
|
|
'EUR GBP',
|
|
'JPY INR BRL',
|
|
])
|
|
def test_zero_balance(currencies):
|
|
keys = currencies.split()
|
|
balance = core.Balance(testutil.Amount(0, key) for key in keys)
|
|
assert balance
|
|
assert len(balance) == len(keys)
|
|
assert balance.is_zero()
|
|
assert all(balance[key].number == 0 for key in keys)
|
|
assert all(balance[key].currency == key for key in keys)
|
|
|
|
@pytest.mark.parametrize('currencies', [
|
|
'USD',
|
|
'EUR GBP',
|
|
'JPY INR BRL',
|
|
])
|
|
def test_nonzero_balance(currencies):
|
|
amounts = dict(zip(currencies.split(), itertools.count(110, 100)))
|
|
balance = core.Balance(amounts_from_map(amounts))
|
|
assert balance
|
|
assert len(balance) == len(amounts)
|
|
assert not balance.is_zero()
|
|
assert all(balance[key] == testutil.Amount(amt, key) for key, amt in amounts.items())
|
|
|
|
def test_mixed_balance():
|
|
amounts = {'USD': 0, 'EUR': 120}
|
|
balance = core.Balance(amounts_from_map(amounts))
|
|
assert balance
|
|
assert len(balance) == 2
|
|
assert not balance.is_zero()
|
|
assert all(balance[key] == testutil.Amount(amt, key) for key, amt in amounts.items())
|
|
|
|
def test_init_recurring_currency():
|
|
balance = core.Balance([
|
|
testutil.Amount(20),
|
|
testutil.Amount(40),
|
|
testutil.Amount(60, 'EUR'),
|
|
testutil.Amount(-80),
|
|
])
|
|
assert balance
|
|
assert balance['EUR'] == testutil.Amount(60, 'EUR')
|
|
assert balance['USD'] == testutil.Amount(-20)
|
|
|
|
def test_init_zeroed_out():
|
|
balance = core.Balance([
|
|
testutil.Amount(25),
|
|
testutil.Amount(40, 'EUR'),
|
|
testutil.Amount(-25),
|
|
testutil.Amount(-40, 'EUR'),
|
|
])
|
|
assert balance.is_zero()
|
|
|
|
@pytest.mark.parametrize('mapping,expected', [
|
|
({}, True),
|
|
({'USD': 0}, True),
|
|
({'USD': 0, 'EUR': 0}, True),
|
|
({'USD': -10, 'EUR': 0}, False),
|
|
({'EUR': -10}, False),
|
|
({'USD': -10, 'EUR': -20}, False),
|
|
({'USD': 10, 'EUR': -20}, False),
|
|
({'JPY': 10}, False),
|
|
({'JPY': 10, 'BRL': 0}, False),
|
|
({'JPY': 10, 'BRL': 20}, False),
|
|
({'USD': '0.00015'}, True),
|
|
({'EUR': '-0.00052'}, True),
|
|
])
|
|
def test_eq_zero(mapping, expected):
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert balance.eq_zero() == expected
|
|
assert balance.is_zero() == expected
|
|
|
|
@pytest.mark.parametrize('mapping,expected', [
|
|
({}, True),
|
|
({'USD': 0}, True),
|
|
({'USD': 0, 'EUR': 0}, True),
|
|
({'EUR': -10}, False),
|
|
({'USD': 10, 'EUR': -20}, False),
|
|
({'USD': -10, 'EUR': -20}, False),
|
|
({'JPY': 10}, True),
|
|
({'JPY': 10, 'BRL': 0}, True),
|
|
({'JPY': 10, 'BRL': 20}, True),
|
|
({'USD': '0.00015'}, True),
|
|
({'EUR': '-0.00052'}, True),
|
|
({'RUB': core.Balance.TOLERANCE}, True),
|
|
({'RUB': -core.Balance.TOLERANCE}, False),
|
|
])
|
|
def test_ge_zero(mapping, expected):
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert balance.ge_zero() == expected
|
|
|
|
@pytest.mark.parametrize('mapping,expected', [
|
|
({}, True),
|
|
({'USD': 0}, True),
|
|
({'USD': 0, 'EUR': 0}, True),
|
|
({'EUR': -10}, True),
|
|
({'USD': 10, 'EUR': -20}, False),
|
|
({'USD': -10, 'EUR': -20}, True),
|
|
({'JPY': 10}, False),
|
|
({'JPY': 10, 'BRL': 0}, False),
|
|
({'JPY': 10, 'BRL': 20}, False),
|
|
({'USD': '0.00015'}, True),
|
|
({'EUR': '-0.00052'}, True),
|
|
({'RUB': core.Balance.TOLERANCE}, False),
|
|
({'RUB': -core.Balance.TOLERANCE}, True),
|
|
])
|
|
def test_le_zero(mapping, expected):
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert balance.le_zero() == expected
|
|
|
|
@pytest.mark.parametrize('mapping', [
|
|
{},
|
|
{'USD': 0},
|
|
{'EUR': 10},
|
|
{'JPY': 20, 'BRL': 30},
|
|
{'EUR': -15},
|
|
{'JPY': -25, 'BRL': -35},
|
|
{'JPY': 40, 'USD': 0, 'EUR': -50},
|
|
])
|
|
def test_abs(mapping):
|
|
actual = abs(core.Balance(amounts_from_map(mapping)))
|
|
assert set(actual) == set(mapping)
|
|
for key, number in mapping.items():
|
|
assert actual[key] == testutil.Amount(abs(number), key)
|
|
|
|
@pytest.mark.parametrize('mapping', [
|
|
{},
|
|
{'USD': 0},
|
|
{'EUR': 10},
|
|
{'JPY': 20, 'BRL': 30},
|
|
{'EUR': -15},
|
|
{'JPY': -25, 'BRL': -35},
|
|
{'JPY': 40, 'USD': 0, 'EUR': -50},
|
|
])
|
|
def test_neg(mapping):
|
|
actual = -core.Balance(amounts_from_map(mapping))
|
|
assert set(actual) == set(mapping)
|
|
for key, number in mapping.items():
|
|
assert actual[key] == testutil.Amount(-number, key)
|
|
|
|
@pytest.mark.parametrize('mapping', [
|
|
{},
|
|
{'USD': 0},
|
|
{'EUR': 10},
|
|
{'JPY': 20, 'BRL': 30},
|
|
{'EUR': -15},
|
|
{'JPY': -25, 'BRL': -35},
|
|
{'JPY': 40, 'USD': 0, 'EUR': -50},
|
|
])
|
|
def test_pos(mapping):
|
|
amounts = frozenset(amounts_from_map(mapping))
|
|
actual = +core.Balance(amounts)
|
|
assert set(actual.values()) == amounts
|
|
|
|
@pytest.mark.parametrize('map1,map2,expected', [
|
|
({}, {}, True),
|
|
({}, {'USD': 0}, True),
|
|
({}, {'EUR': 1}, False),
|
|
({'USD': 1}, {'EUR': 1}, False),
|
|
({'USD': 1}, {'USD': '1.0'}, True),
|
|
({'USD': 1}, {'USD': '1.0', 'EUR': '2.0'}, False),
|
|
({'USD': 1, 'BRL': '2.0'}, {'USD': '1.0', 'EUR': '2.0'}, False),
|
|
({'USD': 1, 'EUR': 2, 'BRL': '3.0'}, {'USD': '1.0', 'EUR': '2.0'}, False),
|
|
({'USD': 1, 'EUR': 2}, {'USD': '1.0', 'EUR': '2.0'}, True),
|
|
])
|
|
def test_eq(map1, map2, expected):
|
|
bal1 = core.Balance(amounts_from_map(map1))
|
|
bal2 = core.Balance(amounts_from_map(map2))
|
|
actual = bal1 == bal2
|
|
assert actual == expected
|
|
|
|
@pytest.mark.parametrize('tolerance', TOLERANCES)
|
|
def test_eq_considers_tolerance(tolerance):
|
|
tolerance = Decimal(tolerance)
|
|
mapping = {'EUR': 100, 'USD': '.002'}
|
|
bal1 = core.Balance(amounts_from_map(mapping))
|
|
mapping['USD'] = '.004'
|
|
bal2 = core.Balance(amounts_from_map(mapping), tolerance)
|
|
assert (bal1 == bal2) == (tolerance > Decimal('.002'))
|
|
|
|
@pytest.mark.parametrize('number,currency', {
|
|
(50, 'USD'),
|
|
(-50, 'USD'),
|
|
(50000, 'BRL'),
|
|
(-4000, 'BRL'),
|
|
})
|
|
def test_add_amount(number, currency):
|
|
start_amount = testutil.Amount(500, 'USD')
|
|
start_bal = core.Balance([start_amount])
|
|
add_amount = testutil.Amount(number, currency)
|
|
actual = start_bal + add_amount
|
|
if currency == 'USD':
|
|
assert len(actual) == 1
|
|
assert actual['USD'] == testutil.Amount(500 + number)
|
|
else:
|
|
assert len(actual) == 2
|
|
assert actual['USD'] == start_amount
|
|
assert actual[currency] == add_amount
|
|
assert start_bal == {'USD': start_amount}
|
|
|
|
@pytest.mark.parametrize('number,currency', {
|
|
(50, 'USD'),
|
|
(-50, 'USD'),
|
|
(50000, 'BRL'),
|
|
(-4000, 'BRL'),
|
|
})
|
|
def test_iadd_amount(number, currency):
|
|
balance = core.MutableBalance([testutil.Amount(500, 'USD')])
|
|
add_amount = testutil.Amount(number, currency)
|
|
balance += add_amount
|
|
if currency == 'USD':
|
|
assert len(balance) == 1
|
|
assert balance['USD'] == testutil.Amount(500 + number)
|
|
else:
|
|
assert len(balance) == 2
|
|
assert balance['USD'] == testutil.Amount(500)
|
|
assert balance[currency] == add_amount
|
|
|
|
@pytest.mark.parametrize('mapping', [
|
|
{},
|
|
{'USD': 0},
|
|
{'EUR': 10},
|
|
{'JPY': 20, 'BRL': 30},
|
|
{'EUR': -15},
|
|
{'JPY': -25, 'BRL': -35},
|
|
{'JPY': 40, 'USD': 0, 'EUR': -50},
|
|
])
|
|
def test_add_balance(mapping):
|
|
expect_numbers = {'USD': 500, 'BRL': 40000}
|
|
start_bal = core.Balance(amounts_from_map(expect_numbers))
|
|
for code, number in mapping.items():
|
|
expect_numbers[code] = expect_numbers.get(code, 0) + number
|
|
add_bal = core.Balance(amounts_from_map(mapping))
|
|
actual = start_bal + add_bal
|
|
expected = core.Balance(amounts_from_map(expect_numbers))
|
|
assert actual == expected
|
|
|
|
@pytest.mark.parametrize('mapping', [
|
|
{},
|
|
{'USD': 0},
|
|
{'EUR': 10},
|
|
{'JPY': 20, 'BRL': 30},
|
|
{'EUR': -15},
|
|
{'JPY': -25, 'BRL': -35},
|
|
{'JPY': 40, 'USD': 0, 'EUR': -50},
|
|
])
|
|
def test_iadd_balance(mapping):
|
|
expect_numbers = {'USD': 500, 'BRL': 40000}
|
|
balance = core.MutableBalance(amounts_from_map(expect_numbers))
|
|
for code, number in mapping.items():
|
|
expect_numbers[code] = expect_numbers.get(code, 0) + number
|
|
balance += core.Balance(amounts_from_map(mapping))
|
|
expected = core.Balance(amounts_from_map(expect_numbers))
|
|
assert balance == expected
|
|
|
|
def test_copy():
|
|
amounts = frozenset(amounts_from_map({'USD': 10, 'EUR': '.001'}))
|
|
# Use a ridiculous tolerance to test it doesn't matter.
|
|
actual = core.Balance(amounts, 100).copy()
|
|
assert frozenset(actual.values()) == amounts
|
|
|
|
@pytest.mark.parametrize('tolerance', TOLERANCES)
|
|
def test_clean_copy(tolerance):
|
|
usd = testutil.Amount(10)
|
|
eur = testutil.Amount('.002', 'EUR')
|
|
actual = core.Balance([usd, eur], tolerance).clean_copy()
|
|
if tolerance < eur.number:
|
|
expected = {usd, eur}
|
|
else:
|
|
expected = {usd}
|
|
assert frozenset(actual.values()) == expected
|
|
|
|
@pytest.mark.parametrize('tolerance', TOLERANCES)
|
|
def test_clean_copy_arg(tolerance):
|
|
usd = testutil.Amount(10)
|
|
eur = testutil.Amount('.002', 'EUR')
|
|
actual = core.Balance([usd, eur], 0).clean_copy(tolerance)
|
|
if tolerance < eur.number:
|
|
expected = {usd, eur}
|
|
else:
|
|
expected = {usd}
|
|
assert frozenset(actual.values()) == expected
|
|
|
|
@pytest.mark.parametrize('mapping,expected', DEFAULT_STRINGS)
|
|
def test_str(mapping, expected):
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert str(balance) == expected
|
|
|
|
@pytest.mark.parametrize('mapping,expected', DEFAULT_STRINGS)
|
|
def test_format_defaults(mapping, expected):
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert balance.format() == expected
|
|
|
|
@pytest.mark.parametrize('fmt,expected', [
|
|
('¤##0.0', '¥5000, -€1500.00'),
|
|
('#,#00.0¤¤', '5,000JPY, -1,500.00EUR'),
|
|
('¤+##0.0;¤-##0.0', '¥+5000, €-1500.00'),
|
|
('#,#00.0 ¤¤;(#,#00.0 ¤¤)', '5,000 JPY, (1,500.00 EUR)'),
|
|
])
|
|
def test_format_fmt(fmt, expected):
|
|
amounts = [testutil.Amount(5000, 'JPY'), testutil.Amount(-1500, 'EUR')]
|
|
balance = core.Balance(amounts)
|
|
assert balance.format(fmt) == expected
|
|
|
|
@pytest.mark.parametrize('sep', [
|
|
'; ',
|
|
'—',
|
|
'\0',
|
|
])
|
|
def test_format_sep(sep):
|
|
mapping, expected = DEFAULT_STRINGS[-1]
|
|
expected = expected.replace(', ', sep)
|
|
balance = core.Balance(amounts_from_map(mapping))
|
|
assert balance.format(sep=sep) == expected
|
|
|
|
def test_format_none():
|
|
args = (65000, 'BRL')
|
|
balance = core.Balance([testutil.Amount(*args)])
|
|
expected = babel.numbers.format_currency(*args)
|
|
assert balance.format(None) == expected
|
|
|
|
@pytest.mark.parametrize('empty', [
|
|
"N/A",
|
|
"Zero",
|
|
"ø",
|
|
])
|
|
def test_format_empty(empty):
|
|
balance = core.Balance()
|
|
assert balance.format(empty=empty) == empty
|
|
|
|
@pytest.mark.parametrize('tolerance', TOLERANCES)
|
|
def test_str_tolerance(tolerance):
|
|
chf = testutil.Amount('.005', 'CHF')
|
|
actual = str(core.Balance([chf], tolerance))
|
|
if tolerance > chf.number:
|
|
assert actual == "Zero balance"
|
|
else:
|
|
assert actual == "00.00 CHF"
|
|
|
|
@pytest.mark.parametrize('tolerance', TOLERANCES)
|
|
def test_format_tolerance(tolerance):
|
|
chf = testutil.Amount('.005', 'CHF')
|
|
actual = core.Balance([chf]).format(tolerance=tolerance)
|
|
if tolerance > chf.number:
|
|
assert actual == "Zero balance"
|
|
else:
|
|
assert actual == "00.00 CHF"
|