#!/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)