test(fusion_accounting_reports): Hypothesis property-based engine invariants
Made-with: Cursor
This commit is contained in:
@@ -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': """
|
||||
|
||||
@@ -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
|
||||
|
||||
156
fusion_accounting_reports/tests/test_engine_property.py
Normal file
156
fusion_accounting_reports/tests/test_engine_property.py
Normal 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)
|
||||
Reference in New Issue
Block a user