From da269a6207ff0a3cfa9109856908a842c406620c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 10:57:41 -0400 Subject: [PATCH] test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants Made-with: Cursor --- fusion_accounting_bank_rec/tests/__init__.py | 1 + .../tests/test_reconcile_engine_property.py | 216 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 4ee84b8f..94e7ac87 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py b/fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py new file mode 100644 index 00000000..08c196c3 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py @@ -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}", + )