diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 0fd4c4aa..68de9744 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Reports', - 'version': '19.0.1.0.16', + 'version': '19.0.1.0.17', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 38c9c8bb..06f06b14 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_fusion_report_anomaly from . import test_reports_controller from . import test_reports_adapter from . import test_fusion_report_tools +from . import test_engine_property diff --git a/fusion_accounting_reports/tests/test_engine_property.py b/fusion_accounting_reports/tests/test_engine_property.py new file mode 100644 index 00000000..3b775fc6 --- /dev/null +++ b/fusion_accounting_reports/tests/test_engine_property.py @@ -0,0 +1,156 @@ +"""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)