"""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}", )