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