test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
Reference in New Issue
Block a user