tests: Test where Transactions are real NamedTuples.
This makes methods like _replace available in real code, and caught the bug where we can't use @functools.lru_cache with Transaction arguments, because they're unhashable due to their mutable members.
This commit is contained in:
parent
e79877ee6a
commit
701ccdc192
6 changed files with 79 additions and 65 deletions
|
@ -31,11 +31,13 @@ from beancount.core import convert as bc_convert
|
||||||
from typing import (
|
from typing import (
|
||||||
cast,
|
cast,
|
||||||
Callable,
|
Callable,
|
||||||
|
Hashable,
|
||||||
Iterable,
|
Iterable,
|
||||||
Iterator,
|
Iterator,
|
||||||
MutableMapping,
|
MutableMapping,
|
||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -310,6 +312,19 @@ class Posting(BasePosting):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_KT = TypeVar('_KT', bound=Hashable)
|
||||||
|
_VT = TypeVar('_VT')
|
||||||
|
class _SizedDict(collections.OrderedDict, MutableMapping[_KT, _VT]):
|
||||||
|
def __init__(self, maxsize: int=128) -> None:
|
||||||
|
self.maxsize = maxsize
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def __setitem__(self, key: _KT, value: _VT) -> None:
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
for _ in range(self.maxsize, len(self)):
|
||||||
|
self.popitem(last=False)
|
||||||
|
|
||||||
|
|
||||||
def balance_of(txn: Transaction,
|
def balance_of(txn: Transaction,
|
||||||
*preds: Callable[[Account], Optional[bool]],
|
*preds: Callable[[Account], Optional[bool]],
|
||||||
) -> Amount:
|
) -> Amount:
|
||||||
|
@ -335,12 +350,23 @@ def balance_of(txn: Transaction,
|
||||||
currency = weights[0].currency
|
currency = weights[0].currency
|
||||||
return Amount(number, currency)
|
return Amount(number, currency)
|
||||||
|
|
||||||
@functools.lru_cache()
|
_opening_balance_cache: MutableMapping[str, bool] = _SizedDict()
|
||||||
def is_opening_balance_txn(txn: Transaction) -> bool:
|
def is_opening_balance_txn(txn: Transaction) -> bool:
|
||||||
|
key = '\0'.join(
|
||||||
|
f'{post.account}={post.units}' for post in txn.postings
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return _opening_balance_cache[key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
opening_equity = balance_of(txn, Account.is_opening_equity)
|
opening_equity = balance_of(txn, Account.is_opening_equity)
|
||||||
if not opening_equity.currency:
|
if not opening_equity.currency:
|
||||||
return False
|
retval = False
|
||||||
rest = balance_of(txn, lambda acct: not acct.is_opening_equity())
|
else:
|
||||||
if not rest.currency:
|
rest = balance_of(txn, lambda acct: not acct.is_opening_equity())
|
||||||
return False
|
if not rest.currency:
|
||||||
return abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
|
retval = False
|
||||||
|
else:
|
||||||
|
retval = abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
|
||||||
|
_opening_balance_cache[key] = retval
|
||||||
|
return retval
|
||||||
|
|
|
@ -23,7 +23,7 @@ from . import testutil
|
||||||
from conservancy_beancount import data
|
from conservancy_beancount import data
|
||||||
|
|
||||||
def test_typical_opening():
|
def test_typical_opening():
|
||||||
txn = testutil.Transaction.opening_balance()
|
txn = testutil.OpeningBalance()
|
||||||
assert data.is_opening_balance_txn(txn)
|
assert data.is_opening_balance_txn(txn)
|
||||||
|
|
||||||
def test_multiacct_opening():
|
def test_multiacct_opening():
|
||||||
|
@ -36,7 +36,7 @@ def test_multiacct_opening():
|
||||||
assert data.is_opening_balance_txn(txn)
|
assert data.is_opening_balance_txn(txn)
|
||||||
|
|
||||||
def test_opening_with_fx():
|
def test_opening_with_fx():
|
||||||
txn = testutil.Transaction.opening_balance()
|
txn = testutil.OpeningBalance()
|
||||||
equity_post = txn.postings[-1]
|
equity_post = txn.postings[-1]
|
||||||
txn.postings[-1] = equity_post._replace(
|
txn.postings[-1] = equity_post._replace(
|
||||||
units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'),
|
units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'),
|
||||||
|
|
|
@ -167,5 +167,5 @@ def test_which_accounts_required_on(hook, account, required):
|
||||||
for error in errors)
|
for error in errors)
|
||||||
|
|
||||||
def test_not_required_on_opening(hook):
|
def test_not_required_on_opening(hook):
|
||||||
txn = testutil.Transaction.opening_balance()
|
txn = testutil.OpeningBalance()
|
||||||
assert not list(hook.run(txn))
|
assert not list(hook.run(txn))
|
||||||
|
|
|
@ -142,5 +142,5 @@ def test_missing_invoice(hook, acct1, acct2):
|
||||||
assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
|
assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
|
||||||
|
|
||||||
def test_not_required_on_opening(hook):
|
def test_not_required_on_opening(hook):
|
||||||
txn = testutil.Transaction.opening_balance()
|
txn = testutil.OpeningBalance()
|
||||||
assert not list(hook.run(txn))
|
assert not list(hook.run(txn))
|
||||||
|
|
|
@ -158,11 +158,11 @@ def test_invalid_project_data(repo_path_s, data_path_s):
|
||||||
meta_project.MetaProject(config, Path(data_path_s))
|
meta_project.MetaProject(config, Path(data_path_s))
|
||||||
|
|
||||||
def test_not_required_on_opening(hook):
|
def test_not_required_on_opening(hook):
|
||||||
txn = testutil.Transaction.opening_balance('Equity:Funds:Unrestricted')
|
txn = testutil.OpeningBalance('Equity:Funds:Unrestricted')
|
||||||
assert not list(hook.run(txn))
|
assert not list(hook.run(txn))
|
||||||
|
|
||||||
def test_always_required_on_restricted_funds(hook):
|
def test_always_required_on_restricted_funds(hook):
|
||||||
acct = 'Equity:Funds:Restricted'
|
acct = 'Equity:Funds:Restricted'
|
||||||
txn = testutil.Transaction.opening_balance(acct)
|
txn = testutil.OpeningBalance(acct)
|
||||||
actual = {error.message for error in hook.run(txn)}
|
actual = {error.message for error in hook.run(txn)}
|
||||||
assert actual == {f'{acct} missing project'}
|
assert actual == {f'{acct} missing project'}
|
||||||
|
|
|
@ -93,6 +93,37 @@ def Posting(account, number,
|
||||||
meta,
|
meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def Transaction(date=FY_MID_DATE, flag='*', payee=None,
|
||||||
|
narration='', tags=None, links=None, postings=(),
|
||||||
|
**meta):
|
||||||
|
if isinstance(date, str):
|
||||||
|
date = parse_date(date)
|
||||||
|
meta.setdefault('filename', '<test>')
|
||||||
|
meta.setdefault('lineno', 0)
|
||||||
|
real_postings = []
|
||||||
|
for post in postings:
|
||||||
|
try:
|
||||||
|
post.account
|
||||||
|
except AttributeError:
|
||||||
|
if isinstance(post[-1], dict):
|
||||||
|
args = post[:-1]
|
||||||
|
kwargs = post[-1]
|
||||||
|
else:
|
||||||
|
args = post
|
||||||
|
kwargs = {}
|
||||||
|
post = Posting(*args, **kwargs)
|
||||||
|
real_postings.append(post)
|
||||||
|
return bc_data.Transaction(
|
||||||
|
meta,
|
||||||
|
date,
|
||||||
|
flag,
|
||||||
|
payee,
|
||||||
|
narration,
|
||||||
|
set(tags or ''),
|
||||||
|
set(links or ''),
|
||||||
|
real_postings,
|
||||||
|
)
|
||||||
|
|
||||||
LINK_METADATA_STRINGS = {
|
LINK_METADATA_STRINGS = {
|
||||||
'Invoices/304321.pdf',
|
'Invoices/304321.pdf',
|
||||||
'rt:123/456',
|
'rt:123/456',
|
||||||
|
@ -130,59 +161,16 @@ def balance_map(source=None, **kwargs):
|
||||||
retval.update(balance_map(kwargs.items()))
|
retval.update(balance_map(kwargs.items()))
|
||||||
return retval
|
return retval
|
||||||
|
|
||||||
class Transaction:
|
def OpeningBalance(acct=None, **txn_meta):
|
||||||
def __init__(self,
|
if acct is None:
|
||||||
date=FY_MID_DATE, flag='*', payee=None,
|
acct = next(OPENING_EQUITY_ACCOUNTS)
|
||||||
narration='', tags=None, links=None, postings=None,
|
return Transaction(**txn_meta, postings=[
|
||||||
**meta):
|
('Assets:Receivable:Accounts', 100),
|
||||||
if isinstance(date, str):
|
('Assets:Receivable:Loans', 200),
|
||||||
date = parse_date(date)
|
('Liabilities:Payable:Accounts', -15),
|
||||||
self.date = date
|
('Liabilities:Payable:Vacation', -25),
|
||||||
self.flag = flag
|
(acct, -260),
|
||||||
self.payee = payee
|
])
|
||||||
self.narration = narration
|
|
||||||
self.tags = set(tags or '')
|
|
||||||
self.links = set(links or '')
|
|
||||||
self.postings = []
|
|
||||||
self.meta = {
|
|
||||||
'filename': '<test>',
|
|
||||||
'lineno': 0,
|
|
||||||
}
|
|
||||||
self.meta.update(meta)
|
|
||||||
if postings is not None:
|
|
||||||
for posting in postings:
|
|
||||||
self.add_posting(*posting)
|
|
||||||
|
|
||||||
def add_posting(self, arg, *args, **kwargs):
|
|
||||||
"""Add a posting to this transaction. Use any of these forms:
|
|
||||||
|
|
||||||
txn.add_posting(account, number, …, kwarg=value, …)
|
|
||||||
txn.add_posting(account, number, …, posting_kwargs_dict)
|
|
||||||
txn.add_posting(posting_object)
|
|
||||||
"""
|
|
||||||
if kwargs:
|
|
||||||
posting = Posting(arg, *args, **kwargs)
|
|
||||||
elif args:
|
|
||||||
if isinstance(args[-1], dict):
|
|
||||||
kwargs = args[-1]
|
|
||||||
args = args[:-1]
|
|
||||||
posting = Posting(arg, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
posting = arg
|
|
||||||
self.postings.append(posting)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def opening_balance(cls, acct=None, **txn_meta):
|
|
||||||
if acct is None:
|
|
||||||
acct = next(OPENING_EQUITY_ACCOUNTS)
|
|
||||||
return cls(**txn_meta, postings=[
|
|
||||||
('Assets:Receivable:Accounts', 100),
|
|
||||||
('Assets:Receivable:Loans', 200),
|
|
||||||
('Liabilities:Payable:Accounts', -15),
|
|
||||||
('Liabilities:Payable:Vacation', -25),
|
|
||||||
(acct, -260),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfig:
|
class TestConfig:
|
||||||
def __init__(self, *,
|
def __init__(self, *,
|
||||||
|
|
Loading…
Reference in a new issue