vendor_bill_editable_totals/tests/run_price_total_test.py
2025-11-21 18:02:20 +07:00

276 lines
9.4 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Standalone test runner for price_total property-based tests.
This allows running tests without the full Odoo test framework.
"""
import sys
import os
from hypothesis import given, settings, strategies as st
from decimal import Decimal
class MockEnv:
"""Mock Odoo environment for testing"""
def __getitem__(self, key):
if key == 'decimal.precision':
return MockDecimalPrecision()
return self
def precision_get(self, name):
return 2 # Default precision for Product Price
class MockDecimalPrecision:
def precision_get(self, name):
return 2
class MockTax:
"""Mock tax record"""
def __init__(self, amount, price_include=False):
self.amount = amount
self.price_include = price_include
def compute_all(self, price_unit, currency, quantity, product, partner):
"""Simplified tax computation"""
total_excluded = price_unit * quantity
if self.price_include:
# Tax is included in the price
# price_unit already includes tax
# We need to extract the tax amount
tax_factor = 1 + (self.amount / 100.0)
actual_excluded = total_excluded / tax_factor
tax_amount = total_excluded - actual_excluded
total_included = total_excluded
total_excluded = actual_excluded
else:
# Tax is excluded from the price
tax_amount = total_excluded * (self.amount / 100.0)
total_included = total_excluded + tax_amount
return {
'total_excluded': total_excluded,
'total_included': total_included,
'taxes': [{'amount': tax_amount}]
}
class MockTaxCollection:
"""Mock collection of taxes"""
def __init__(self, taxes):
self.taxes = taxes
def compute_all(self, price_unit, currency, quantity, product, partner):
"""Compute taxes for a collection"""
if not self.taxes:
total = price_unit * quantity
return {
'total_excluded': total,
'total_included': total,
'taxes': []
}
# Start with the base amount
total_excluded = price_unit * quantity
total_included = total_excluded
# Apply each tax
for tax in self.taxes:
if tax.price_include:
# For tax-included, we need to extract the tax from the current total
tax_factor = 1 + (tax.amount / 100.0)
actual_excluded = total_included / tax_factor
total_excluded = actual_excluded
else:
# For tax-excluded, add the tax to the total
tax_amount = total_excluded * (tax.amount / 100.0)
total_included += tax_amount
return {
'total_excluded': total_excluded,
'total_included': total_included,
'taxes': []
}
def __bool__(self):
return len(self.taxes) > 0
def __len__(self):
return len(self.taxes)
class MockLine:
"""Mock account.move.line for testing"""
def __init__(self, taxes=None):
self.price_total = 0.0
self.quantity = 1.0
self.price_unit = 0.0
self.move_id = MockMove()
self.env = MockEnv()
self.currency_id = None
self.product_id = None
self.tax_ids = MockTaxCollection(taxes or [])
def _onchange_price_total(self):
"""Simplified version of the onchange method"""
if self.move_id.move_type not in ('in_invoice', 'in_refund'):
return
if self.quantity == 0:
raise ValueError("Cannot calculate unit price: quantity must be greater than zero")
# Handle case with no taxes: price_total equals price_subtotal
if not self.tax_ids:
new_price_unit = self.price_total / self.quantity
precision = self.env['decimal.precision'].precision_get('Product Price')
self.price_unit = round(new_price_unit, precision)
return
# Check if any taxes are price-included
has_price_included_tax = any(tax.price_include for tax in self.tax_ids.taxes)
if has_price_included_tax:
# For tax-included taxes, price_unit = price_total / quantity
new_price_unit = self.price_total / self.quantity
precision = self.env['decimal.precision'].precision_get('Product Price')
self.price_unit = round(new_price_unit, precision)
else:
# For tax-excluded taxes, calculate tax factor
tax_results = self.tax_ids.compute_all(
price_unit=1.0,
currency=self.currency_id,
quantity=1.0,
product=self.product_id,
partner=self.move_id.partner_id
)
# Calculate the tax factor (total_included / total_excluded)
if tax_results['total_excluded'] != 0:
tax_factor = tax_results['total_included'] / tax_results['total_excluded']
else:
tax_factor = 1.0
# Derive price_subtotal from price_total
derived_price_subtotal = self.price_total / tax_factor
# Calculate price_unit from derived price_subtotal
new_price_unit = derived_price_subtotal / self.quantity
# Apply decimal precision rounding
precision = self.env['decimal.precision'].precision_get('Product Price')
self.price_unit = round(new_price_unit, precision)
class MockMove:
def __init__(self):
self.move_type = 'in_invoice'
self.partner_id = None
@given(
price_total=st.floats(min_value=0.01, max_value=1000000.0, allow_nan=False, allow_infinity=False),
quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
tax_config=st.integers(min_value=0, max_value=5)
)
@settings(max_examples=100, deadline=None)
def test_property_price_total_with_taxes(price_total, quantity, tax_config):
"""
**Feature: vendor-bill-editable-totals, Property 4: Price total to unit price calculation with taxes**
**Validates: Requirements 2.2, 2.3, 2.4**
Property: For any invoice line with non-zero quantity, configured taxes, and a
user-modified price_total value, the recalculated price_unit should result in a
recomputed price_total that matches the user input within the configured decimal precision.
"""
precision = 2 # Product Price precision
# Map tax_config to different tax configurations
taxes = []
if tax_config == 0:
taxes = []
elif tax_config == 1:
taxes = [MockTax(10.0, False)]
elif tax_config == 2:
taxes = [MockTax(20.0, False)]
elif tax_config == 3:
taxes = [MockTax(10.0, False), MockTax(5.0, False)]
elif tax_config == 4:
taxes = [MockTax(20.0, False), MockTax(5.0, False)]
elif tax_config == 5:
taxes = [MockTax(15.0, True)]
# Create a mock line
line = MockLine(taxes)
line.quantity = quantity
line.price_unit = 1.0
# Store the user input price_total
user_input_total = price_total
# Step 1: Set the price_total to trigger the onchange
line.price_total = user_input_total
line._onchange_price_total()
# Step 2: Calculate what the recomputed price_total should be
if not line.tax_ids:
# No taxes: price_total should equal price_subtotal
recomputed_total = line.price_unit * line.quantity
else:
# With taxes: use tax computation
tax_results = line.tax_ids.compute_all(
price_unit=line.price_unit,
currency=line.currency_id,
quantity=line.quantity,
product=line.product_id,
partner=line.move_id.partner_id
)
recomputed_total = tax_results['total_included']
# Calculate the tolerance
unit_rounding_error = 0.5 * (10 ** (-precision))
base_tolerance = quantity * unit_rounding_error
if taxes:
# With taxes, allow for additional rounding
tolerance = max(base_tolerance * 2, user_input_total * 0.0001)
else:
tolerance = base_tolerance * 1.1
# Verify the property
difference = abs(recomputed_total - user_input_total)
assert difference <= tolerance, \
f"Round-trip accuracy failed for tax_config={tax_config}: " \
f"user input={user_input_total:.{precision}f}, " \
f"price_unit={line.price_unit:.{precision}f}, " \
f"quantity={quantity:.2f}, " \
f"recomputed={recomputed_total:.{precision}f}, " \
f"difference={difference:.{precision+2}f}, " \
f"tolerance={tolerance:.{precision+2}f}"
print(f"✓ Test passed: tax_config={tax_config}, input={user_input_total:.2f}, "
f"price_unit={line.price_unit:.2f}, recomputed={recomputed_total:.2f}, diff={difference:.4f}")
if __name__ == '__main__':
print("Running Property Test 4: Price total to unit price calculation with taxes")
print("=" * 70)
try:
test_property_price_total_with_taxes()
print("\n✓ Property Test 4 passed!")
sys.exit(0)
except Exception as e:
print(f"\n✗ Property Test 4 failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)