test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 10:57:41 -04:00
parent 80b8100232
commit da269a6207
2 changed files with 217 additions and 0 deletions

View File

@@ -6,3 +6,4 @@ from . import test_precedent_lookup
from . import test_pattern_extraction
from . import test_confidence_scoring
from . import test_reconcile_engine_unit
from . import test_reconcile_engine_property

View File

@@ -0,0 +1,216 @@
"""Property-based tests for reconcile engine invariants.
Hypothesis generates random input combinations to catch edge cases that
example-based TDD missed. Each test runs N times (default 50 -- bumpable
via @settings)."""
from datetime import date
from hypothesis import HealthCheck, given, settings, strategies as st
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
AmountExactStrategy,
Candidate,
FIFOStrategy,
MultiInvoiceStrategy,
)
@tagged('post_install', '-at_install', 'property_based')
class TestMatchingStrategyInvariants(TransactionCase):
"""Pure-Python invariants on the matching strategies (no ORM needed).
Faster + more iterations than DB-backed property tests."""
@given(
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
allow_nan=False, allow_infinity=False),
invoice_amounts=st.lists(
st.floats(min_value=0.01, max_value=100000.00,
allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
),
)
@settings(max_examples=100, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_amount_exact_picks_only_when_amount_matches(
self, bank_amount, invoice_amounts):
"""AmountExactStrategy returns picks IFF some candidate amount matches
bank_amount within tolerance."""
candidates = [
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
for i, amt in enumerate(invoice_amounts)
]
bank_amount = round(bank_amount, 2)
result = AmountExactStrategy().match(
bank_amount=bank_amount, candidates=candidates)
has_match = any(
abs(c.amount - bank_amount) < 0.005 for c in candidates)
if has_match:
self.assertEqual(
len(result.picked_ids), 1,
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
)
self.assertEqual(result.confidence, 1.0)
else:
self.assertEqual(result.picked_ids, [])
@given(
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
allow_nan=False, allow_infinity=False),
invoice_amounts=st.lists(
st.floats(min_value=1.00, max_value=10000.00,
allow_nan=False, allow_infinity=False),
min_size=1, max_size=8,
),
)
@settings(max_examples=100, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
"""FIFOStrategy picks candidates in order of decreasing age_days
(oldest first), stopping when remaining <= 0."""
candidates = [
Candidate(id=i, amount=round(amt, 2), partner_id=1,
age_days=100 - i)
for i, amt in enumerate(invoice_amounts)
]
bank_amount = round(bank_amount, 2)
result = FIFOStrategy().match(
bank_amount=bank_amount, candidates=candidates)
if not candidates:
return
oldest_first_ids = [
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
self.assertEqual(
result.picked_ids,
oldest_first_ids[:len(result.picked_ids)],
)
picked_sum = sum(
c.amount for c in candidates if c.id in result.picked_ids)
self.assertAlmostEqual(
result.residual, bank_amount - picked_sum, places=2)
@given(
amounts=st.lists(
st.floats(min_value=1.00, max_value=1000.00,
allow_nan=False, allow_infinity=False),
min_size=2, max_size=6,
),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
finds SOME valid combination."""
rounded = [round(a, 2) for a in amounts]
candidates = [
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
for i, amt in enumerate(rounded)
]
target = round(rounded[0] + rounded[1], 2)
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=target, candidates=candidates)
if result.picked_ids:
picked_sum = sum(
c.amount for c in candidates if c.id in result.picked_ids)
self.assertAlmostEqual(
picked_sum, target, places=2,
msg=(f"target={target} picks={result.picked_ids} "
f"sum={picked_sum} candidates={rounded}"),
)
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
class TestReconcileEngineInvariants(TransactionCase):
"""ORM-backed property tests against the engine.
Slower because each test creates real bank_lines + invoices."""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create(
{'name': 'Engine Property Partner'})
self.journal = self.env['account.journal'].create({
'name': 'Engine Property Bank',
'type': 'bank',
'code': 'EPB',
})
self.receivable_account = self.env['account.account'].search([
('account_type', '=', 'asset_receivable'),
('company_ids', 'in', self.env.company.id),
], limit=1)
if not self.receivable_account:
self.skipTest("No receivable account in chart of accounts")
def _make_bank_line(self, amount):
statement = self.env['account.bank.statement'].create({
'name': f'Test stmt {amount}',
'journal_id': self.journal.id,
})
return self.env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': self.journal.id,
'date': date.today(),
'payment_ref': f'Test {amount}',
'amount': amount,
'partner_id': self.partner.id,
})
def _make_invoice(self, amount):
product = self.env['product.product'].search(
[('type', '=', 'service')], limit=1)
if not product:
product = self.env['product.product'].create({
'name': 'Property Test Service',
'type': 'service',
})
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
'invoice_date': date.today(),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Property Test',
'quantity': 1,
'price_unit': amount,
})],
})
move.action_post()
return move
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
allow_nan=False, allow_infinity=False))
@settings(max_examples=10, deadline=10000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invariant_simple_reconcile_balances(self, amount):
"""For any bank_amount = invoice_amount, reconcile_one produces:
- exactly 1 partial reconcile
- amount equal to the bank line amount
- bank line is_reconciled = True"""
amount = round(amount, 2)
bank_line = self._make_bank_line(amount)
invoice = self._make_invoice(amount)
invoice_recv_lines = invoice.line_ids.filtered(
lambda line: line.account_id.account_type == 'asset_receivable')
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=invoice_recv_lines)
self.assertGreater(
len(result['partial_ids']), 0,
f"Expected partial_ids non-empty for amount={amount}, got {result}",
)
partials = self.env['account.partial.reconcile'].browse(
result['partial_ids'])
self.assertAlmostEqual(
sum(partials.mapped('amount')), amount, places=2)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(
bank_line.is_reconciled,
f"is_reconciled expected True after reconcile for amount={amount}",
)