157 lines
6.3 KiB
Python
157 lines
6.3 KiB
Python
"""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)
|