"""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'))