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:
Brett Smith 2020-04-28 15:33:30 -04:00
parent e79877ee6a
commit 701ccdc192
6 changed files with 79 additions and 65 deletions

View file

@ -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

View file

@ -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'),

View file

@ -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))

View file

@ -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))

View file

@ -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'}

View file

@ -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, *,