321 lines
11 KiB
Python
Executable File
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)
|