diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 76912615..583b044b 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_followup_controller from . import test_followup_adapter from . import test_followup_tools from . import test_followup_cron +from . import test_engine_property diff --git a/fusion_accounting_followup/tests/test_engine_property.py b/fusion_accounting_followup/tests/test_engine_property.py new file mode 100644 index 00000000..99b6679d --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_property.py @@ -0,0 +1,92 @@ +"""Property-based invariants for follow-up services.""" + +from datetime import date, timedelta + +from hypothesis import given, settings, strategies as st, HealthCheck +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + +from odoo.addons.fusion_accounting_followup.services.overdue_aging import ( + compute_aging, BUCKETS, +) +from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner +from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone + + +@tagged('post_install', '-at_install', 'property_based') +class TestAgingInvariants(TransactionCase): + + @given( + as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)), + amounts=st.lists( + st.tuples( + st.integers(min_value=-180, max_value=180), + st.floats(min_value=0.01, max_value=100000, + allow_nan=False, allow_infinity=False), + ), + min_size=0, max_size=20, + ), + ) + @settings(max_examples=80, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_buckets_sum_equals_total(self, as_of, amounts): + lines = [ + {'date_maturity': as_of + timedelta(days=offset), + 'amount_residual': round(amt, 2)} + for offset, amt in amounts + ] + report = compute_aging(move_lines=lines, as_of=as_of) + bucket_sum = sum(b.amount for b in report.buckets) + self.assertAlmostEqual(bucket_sum, report.total_amount, places=1) + + @given( + as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)), + days_overdue=st.integers(min_value=1, max_value=365), + amount=st.floats(min_value=0.01, max_value=10000, + allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=50, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount): + lines = [ + {'date_maturity': as_of - timedelta(days=days_overdue), + 'amount_residual': round(amount, 2)}, + {'date_maturity': as_of + timedelta(days=10), + 'amount_residual': 100.0}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1) + + @given( + invoices=st.integers(min_value=0, max_value=100), + late=st.integers(min_value=0, max_value=100), + days_late=st.floats(min_value=0, max_value=180, + allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=80, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_risk_score_in_range(self, invoices, late, days_late): + late = min(late, invoices) if invoices > 0 else 0 + result = score_partner( + total_invoices=invoices, paid_late_count=late, + avg_days_late=days_late, + longest_overdue_days=int(days_late), + open_overdue_amount=invoices * 1000.0, + average_invoice_amount=1000.0, + ) + self.assertGreaterEqual(result.score, 0) + self.assertLessEqual(result.score, 100) + + +@tagged('post_install', '-at_install', 'property_based') +class TestToneInvariants(TransactionCase): + + @given( + sequence=st.integers(min_value=1, max_value=10), + risk=st.integers(min_value=0, max_value=100), + ) + @settings(max_examples=50, deadline=1000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_tone_always_in_valid_set(self, sequence, risk): + tone = select_tone(level_sequence=sequence, risk_score=risk) + self.assertIn(tone, ('gentle', 'firm', 'legal'))