From 1e70b8d5c06db8e596da9d4b7a0e046fb75f8b84 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 17:22:55 -0400 Subject: [PATCH] test(fusion_accounting_assets): Hypothesis property-based depreciation invariants Made-with: Cursor --- fusion_accounting_assets/tests/__init__.py | 1 + .../tests/test_engine_property.py | 101 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 fusion_accounting_assets/tests/test_engine_property.py diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index 9a0e65ee..2acadf6e 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_assets_controller from . import test_assets_adapter from . import test_asset_tools from . import test_assets_cron +from . import test_engine_property diff --git a/fusion_accounting_assets/tests/test_engine_property.py b/fusion_accounting_assets/tests/test_engine_property.py new file mode 100644 index 00000000..e211b4a1 --- /dev/null +++ b/fusion_accounting_assets/tests/test_engine_property.py @@ -0,0 +1,101 @@ +"""Property-based invariant tests for the asset engine. + +Hypothesis generates random inputs; we assert mathematical invariants +that must hold regardless of input.""" + +from hypothesis import given, settings, strategies as st, HealthCheck +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + +from odoo.addons.fusion_accounting_assets.services.depreciation_methods import ( + straight_line, declining_balance, units_of_production, +) + + +@tagged('post_install', '-at_install', 'property_based') +class TestDepreciationInvariants(TransactionCase): + + @given( + cost=st.floats(min_value=100.0, max_value=1000000.0, + allow_nan=False, allow_infinity=False), + salvage_pct=st.floats(min_value=0.0, max_value=0.5, + allow_nan=False, allow_infinity=False), + n_periods=st.integers(min_value=1, max_value=40), + ) + @settings(max_examples=80, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_straight_line_total_equals_cost_minus_salvage(self, cost, salvage_pct, n_periods): + cost = round(cost, 2) + salvage = round(cost * salvage_pct, 2) + steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods) + total = sum(s.period_amount for s in steps) + # Within 1c rounding tolerance + self.assertAlmostEqual( + total, cost - salvage, places=1, + msg=f"cost={cost}, salvage={salvage}, n={n_periods}, total={total:.2f}", + ) + + @given( + cost=st.floats(min_value=100.0, max_value=1000000.0, + allow_nan=False, allow_infinity=False), + salvage_pct=st.floats(min_value=0.0, max_value=0.5, + allow_nan=False, allow_infinity=False), + n_periods=st.integers(min_value=1, max_value=20), + ) + @settings(max_examples=50, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_straight_line_book_value_decreasing(self, cost, salvage_pct, n_periods): + cost = round(cost, 2) + salvage = round(cost * salvage_pct, 2) + steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods) + for i in range(1, len(steps)): + self.assertLessEqual( + steps[i].book_value_at_end, + steps[i - 1].book_value_at_end + 0.01, + ) + + @given( + cost=st.floats(min_value=1000.0, max_value=100000.0, + allow_nan=False, allow_infinity=False), + salvage_pct=st.floats(min_value=0.0, max_value=0.3, + allow_nan=False, allow_infinity=False), + n_periods=st.integers(min_value=2, max_value=20), + rate=st.floats(min_value=0.05, max_value=0.5, + allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=50, deadline=3000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_declining_balance_never_below_salvage(self, cost, salvage_pct, n_periods, rate): + cost = round(cost, 2) + salvage = round(cost * salvage_pct, 2) + steps = declining_balance( + cost=cost, salvage_value=salvage, + n_periods=n_periods, rate=rate, + ) + for s in steps: + self.assertGreaterEqual( + s.book_value_at_end, salvage - 0.01, + msg=f"cost={cost}, salvage={salvage}, rate={rate}, step={s}", + ) + + @given( + cost=st.floats(min_value=1000.0, max_value=100000.0, + allow_nan=False, allow_infinity=False), + total_units=st.floats(min_value=100.0, max_value=10000.0, + allow_nan=False, allow_infinity=False), + n_periods=st.integers(min_value=1, max_value=10), + ) + @settings(max_examples=30, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_units_of_production_total_at_full_use_equals_depreciable(self, cost, total_units, n_periods): + cost = round(cost, 2) + salvage = 0.0 + # Distribute total_units evenly across periods + per_period = total_units / n_periods + steps = units_of_production( + cost=cost, salvage_value=salvage, + total_units_expected=total_units, + units_per_period=[per_period] * n_periods, + ) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, cost - salvage, places=1)