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

321 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Standalone test runner for decimal precision property-based tests.
This allows running tests without the full Odoo test framework.
"""
import sys
import os
# Add the Odoo directory to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../../odoo'))
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 for testing"""
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
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 MockTaxIds:
"""Mock tax_ids recordset"""
def __init__(self, taxes):
self.taxes = taxes
def compute_all(self, price_unit, currency, quantity, product, partner):
if not self.taxes:
return {
'total_excluded': price_unit * quantity,
'total_included': price_unit * quantity,
'taxes': []
}
# For simplicity, just use the first tax
return self.taxes[0].compute_all(price_unit, currency, quantity, product, partner)
def __iter__(self):
return iter(self.taxes)
def __bool__(self):
return len(self.taxes) > 0
class MockLine:
"""Mock account.move.line for testing"""
def __init__(self, use_tax=False):
self.price_subtotal = 0.0
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
# Set up tax_ids
if use_tax:
self.tax_ids = MockTaxIds([MockTax(10.0, False)])
else:
self.tax_ids = MockTaxIds([])
def _onchange_price_subtotal(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")
new_price_unit = self.price_subtotal / self.quantity
precision = self.env['decimal.precision'].precision_get('Product Price')
self.price_unit = round(new_price_unit, precision)
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
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)
if has_price_included_tax:
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:
# 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
)
if tax_results['total_excluded'] != 0:
tax_factor = tax_results['total_included'] / tax_results['total_excluded']
else:
tax_factor = 1.0
derived_price_subtotal = self.price_total / tax_factor
new_price_unit = derived_price_subtotal / self.quantity
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
def count_decimal_places(value):
"""Helper function to count the number of decimal places in a float."""
# Convert to Decimal for accurate decimal place counting
from decimal import Decimal
# Use string conversion to avoid floating point representation issues
value_str = str(value)
if '.' in value_str:
# Remove trailing zeros
value_str = value_str.rstrip('0').rstrip('.')
if '.' in value_str:
return len(value_str.split('.')[1])
return 0
@given(
price_subtotal=st.floats(
min_value=0.001,
max_value=1000000.0,
allow_nan=False,
allow_infinity=False
).map(lambda x: round(x, 10)),
quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
)
@settings(max_examples=100, deadline=None)
def test_property_decimal_precision_price_subtotal(price_subtotal, quantity):
"""
**Feature: vendor-bill-editable-totals, Property 5: Decimal precision compliance**
**Validates: Requirements 3.5**
Property: For any calculated price_unit value from price_subtotal modification,
the system should round the value according to Odoo's configured decimal precision
for the Product Price field.
"""
precision = 2 # Product Price precision
# Create a mock line
line = MockLine(use_tax=False)
line.quantity = quantity
line.price_unit = 1.0
# Set the price_subtotal to trigger the onchange
line.price_subtotal = price_subtotal
line._onchange_price_subtotal()
# Count decimal places in the calculated price_unit
decimal_places = count_decimal_places(line.price_unit)
# Verify the property: price_unit should have no more decimal places
# than the configured precision
assert decimal_places <= precision, \
f"Decimal precision violation: price_unit={line.price_unit} has " \
f"{decimal_places} decimal places, but precision is {precision}. " \
f"Input: price_subtotal={price_subtotal}, quantity={quantity}"
# Also verify that the value is properly rounded
expected_rounded = round(price_subtotal / quantity, precision)
assert line.price_unit == expected_rounded, \
f"Price unit not properly rounded: expected {expected_rounded}, got {line.price_unit}"
print(f"✓ Test passed: price_subtotal={price_subtotal:.10f}, quantity={quantity:.2f}, "
f"price_unit={line.price_unit:.{precision}f}, decimal_places={decimal_places}")
@given(
price_total=st.floats(
min_value=0.001,
max_value=1000000.0,
allow_nan=False,
allow_infinity=False
).map(lambda x: round(x, 10)),
quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
use_tax=st.booleans()
)
@settings(max_examples=100, deadline=None)
def test_property_decimal_precision_price_total(price_total, quantity, use_tax):
"""
**Feature: vendor-bill-editable-totals, Property 5: Decimal precision compliance**
**Validates: Requirements 3.5**
Property: For any calculated price_unit value from price_total modification,
the system should round the value according to Odoo's configured decimal precision
for the Product Price field.
"""
precision = 2 # Product Price precision
# Create a mock line with or without tax
line = MockLine(use_tax=use_tax)
line.quantity = quantity
line.price_unit = 1.0
# Set the price_total to trigger the onchange
line.price_total = price_total
line._onchange_price_total()
# Count decimal places in the calculated price_unit
decimal_places = count_decimal_places(line.price_unit)
# Verify the property: price_unit should have no more decimal places
# than the configured precision
assert decimal_places <= precision, \
f"Decimal precision violation: price_unit={line.price_unit} has " \
f"{decimal_places} decimal places, but precision is {precision}. " \
f"Input: price_total={price_total}, quantity={quantity}, use_tax={use_tax}"
# Calculate expected value based on whether tax is used
if use_tax:
# With tax: need to account for tax factor
tax_results = line.tax_ids.compute_all(
price_unit=1.0,
currency=line.currency_id,
quantity=1.0,
product=line.product_id,
partner=line.move_id.partner_id
)
tax_factor = tax_results['total_included'] / tax_results['total_excluded']
expected_price_unit = (price_total / tax_factor) / quantity
else:
# Without tax: price_total equals price_subtotal
expected_price_unit = price_total / quantity
expected_rounded = round(expected_price_unit, precision)
# Verify that the value is properly rounded
assert line.price_unit == expected_rounded, \
f"Price unit not properly rounded: expected {expected_rounded}, got {line.price_unit}"
print(f"✓ Test passed: price_total={price_total:.10f}, quantity={quantity:.2f}, "
f"use_tax={use_tax}, price_unit={line.price_unit:.{precision}f}, "
f"decimal_places={decimal_places}")
if __name__ == '__main__':
print("Running Decimal Precision Property Tests for vendor-bill-editable-totals")
print("=" * 80)
all_passed = True
# Test 1: Decimal precision for price_subtotal
print("\nProperty Test 5a: Decimal precision compliance (price_subtotal)")
print("-" * 80)
try:
test_property_decimal_precision_price_subtotal()
print("✓ Property Test 5a passed!")
except Exception as e:
print(f"✗ Property Test 5a failed: {e}")
all_passed = False
# Test 2: Decimal precision for price_total
print("\n" + "=" * 80)
print("\nProperty Test 5b: Decimal precision compliance (price_total)")
print("-" * 80)
try:
test_property_decimal_precision_price_total()
print("✓ Property Test 5b passed!")
except Exception as e:
print(f"✗ Property Test 5b failed: {e}")
all_passed = False
print("\n" + "=" * 80)
if all_passed:
print("✓ All decimal precision property tests passed!")
sys.exit(0)
else:
print("✗ Some decimal precision property tests failed!")
sys.exit(1)