"""Property-based invariant tests for the reports engine. Hypothesis generates random scenarios; we assert mathematical invariants that must hold regardless of input.""" from datetime import date, timedelta from hypothesis import HealthCheck, given, settings, strategies as st from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_reports.services.date_periods import ( Period, comparison_period, fiscal_year_bounds, month_bounds, quarter_bounds, ) from odoo.addons.fusion_accounting_reports.services.line_resolver import resolve from odoo.addons.fusion_accounting_reports.services.totaling import ( TotalLine, aggregate, is_balanced, ) @tagged('post_install', '-at_install', 'property_based') class TestServiceInvariants(TransactionCase): """Pure-Python invariants - fast, no DB writes.""" @given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))) @settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_fiscal_year_contains_reference_date(self, d): period = fiscal_year_bounds(d) self.assertLessEqual(period.date_from, d) self.assertGreaterEqual(period.date_to, d) @given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))) @settings(max_examples=50, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_month_bounds_first_to_last_day(self, d): period = month_bounds(d) self.assertEqual(period.date_from.day, 1) # Last day of month: adding 1 day rolls into the next month next_day = period.date_to + timedelta(days=1) self.assertNotEqual(next_day.month, period.date_to.month) @given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))) @settings(max_examples=50, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_quarter_bounds_three_months(self, d): period = quarter_bounds(d) # Quarter starts on month 1, 4, 7, or 10 and is exactly 3 months self.assertIn(period.date_from.month, (1, 4, 7, 10)) self.assertEqual(period.date_from.day, 1) self.assertGreaterEqual(period.date_to, d) self.assertLessEqual(period.date_from, d) @given( debits=st.lists( st.floats(min_value=0, max_value=10000, allow_nan=False, allow_infinity=False), min_size=1, max_size=20, ), ) @settings(max_examples=50, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_aggregate_sum_equals_input_sum(self, debits): lines = [ {'debit': d, 'credit': 0, 'balance': d, 'account_id': 1} for d in debits ] result = aggregate(lines) self.assertAlmostEqual(result.debit, sum(debits), places=2) self.assertEqual(result.line_count, len(lines)) @given( amounts=st.lists( st.floats(min_value=1.0, max_value=100000, allow_nan=False, allow_infinity=False), min_size=4, max_size=10, ), ) @settings(max_examples=50, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_balanced_iff_debits_equal_credits(self, amounts): # Build a perfectly balanced ledger: half debits, half credits scaled # so the totals match exactly. half = len(amounts) // 2 debits = amounts[:half] credits = amounts[half:half * 2] if not credits or sum(credits) == 0: return scale = sum(debits) / sum(credits) scaled_credits = [c * scale for c in credits] lines = [{'debit': d, 'credit': 0, 'balance': d} for d in debits] lines += [ {'debit': 0, 'credit': c, 'balance': -c} for c in scaled_credits ] # Allow a generous tolerance to account for float scaling drift on # extreme inputs; the invariant we care about is still that balanced # books read as balanced. self.assertTrue(is_balanced(lines, tolerance=1.0)) @given( period_from=st.dates(min_value=date(2021, 1, 1), max_value=date(2026, 1, 1)), ) @settings(max_examples=30, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_comparison_previous_year_is_one_year_earlier(self, period_from): # Build a 30-day period to keep things simple period_to = period_from + timedelta(days=30) period = Period(period_from, period_to, 'test') comp = comparison_period(period, 'previous_year') self.assertIsNotNone(comp) self.assertEqual(comp.date_from.year, period.date_from.year - 1) self.assertEqual(comp.date_to.year, period.date_to.year - 1) @tagged('post_install', '-at_install', 'property_based') class TestLineResolverInvariants(TransactionCase): """Invariants on the line_resolver.""" @given( n_accounts=st.integers(min_value=1, max_value=20), balance=st.floats(min_value=-10000, max_value=10000, allow_nan=False, allow_infinity=False), ) @settings(max_examples=30, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_subtotal_equals_sum_of_above_rows(self, n_accounts, balance): accounts_by_id = { i: {'code': f'{i:04d}', 'name': f'Acct {i}', 'account_type': 'asset_cash'} for i in range(n_accounts) } account_totals = { i: TotalLine(balance=balance) for i in range(n_accounts) } line_specs = [ {'label': f'Acct {i}', 'account_id': i, 'sign': 1} for i in range(n_accounts) ] line_specs.append({ 'label': 'Subtotal', 'compute': 'subtotal', 'above': n_accounts, 'sign': 1, }) rows = resolve(line_specs, account_totals=account_totals, accounts_by_id=accounts_by_id) subtotal = rows[-1] non_subtotals = [r for r in rows[:-1] if not r.get('is_subtotal')] expected = sum(r['amount'] for r in non_subtotals) self.assertAlmostEqual(subtotal['amount'], expected, places=2)