test(fusion_accounting_reports): Hypothesis property-based engine invariants

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 15:48:56 -04:00
parent 118f0d9d16
commit 144e90a379
3 changed files with 158 additions and 1 deletions

View File

@@ -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': """

View File

@@ -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

View File

@@ -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)