#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Standalone test runner for edge case unit tests. This allows running tests without the full Odoo test framework. """ import sys import os 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, name, amount, price_include=False): self.name = name self.amount = amount self.price_include = price_include def compute_all(self, price_unit, currency, quantity, product, partner): """Compute tax amounts""" total_excluded = price_unit * quantity if self.price_include: # For price-included taxes, the price_unit already includes tax # So total_included = total_excluded total_included = total_excluded else: # For price-excluded taxes, add the tax to get total_included tax_amount = total_excluded * (self.amount / 100.0) total_included = total_excluded + tax_amount return { 'total_excluded': total_excluded, 'total_included': total_included, } class MockTaxIds: """Mock tax_ids recordset""" def __init__(self, taxes): self.taxes = taxes def __iter__(self): return iter(self.taxes) def __bool__(self): return len(self.taxes) > 0 def compute_all(self, price_unit, currency, quantity, product, partner): """Compute combined tax amounts for multiple taxes""" total_excluded = price_unit * quantity total_included = total_excluded # Calculate combined tax effect for tax in self.taxes: if not tax.price_include: tax_amount = total_excluded * (tax.amount / 100.0) total_included += tax_amount return { 'total_excluded': total_excluded, 'total_included': total_included, } class MockLine: """Mock account.move.line for testing""" def __init__(self, move_type='in_invoice'): self.price_subtotal = 0.0 self.price_total = 0.0 self.quantity = 1.0 self.price_unit = 0.0 self.move_id = MockMove(move_type) self.env = MockEnv() self.tax_ids = MockTaxIds([]) self.currency_id = None self.product_id = None def set_taxes(self, taxes): """Set tax_ids for the line""" self.tax_ids = MockTaxIds(taxes) 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 for price_total""" 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) 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 the 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=None ) # Calculate the tax factor 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, move_type='in_invoice'): self.move_type = move_type def test_zero_quantity_price_subtotal_raises_error(): """ Test that modifying price_subtotal with zero quantity raises error. Requirement 3.1: Division by zero protection """ line = MockLine() line.quantity = 0.0 line.price_unit = 100.0 line.price_subtotal = 500.0 try: line._onchange_price_subtotal() return False, "Expected ValueError but no exception was raised" except ValueError as e: if 'quantity must be greater than zero' in str(e): return True, "Correctly raised error for zero quantity" else: return False, f"Wrong error message: {e}" def test_zero_quantity_price_total_raises_error(): """ Test that modifying price_total with zero quantity raises error. Requirement 3.1: Division by zero protection """ line = MockLine() line.quantity = 0.0 line.price_unit = 100.0 line.price_total = 550.0 try: line._onchange_price_total() return False, "Expected ValueError but no exception was raised" except ValueError as e: if 'quantity must be greater than zero' in str(e): return True, "Correctly raised error for zero quantity" else: return False, f"Wrong error message: {e}" def test_negative_price_subtotal_credit_note(): """ Test that negative price_subtotal values are handled correctly for credit notes. Requirement 3.2: Accept and process negative values correctly """ line = MockLine(move_type='in_refund') line.quantity = 5.0 line.price_unit = 100.0 line.price_subtotal = -500.0 line._onchange_price_subtotal() expected_price_unit = -100.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated negative price_unit: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_negative_price_total_credit_note(): """ Test that negative price_total values are handled correctly for credit notes. Requirement 3.2: Accept and process negative values correctly """ line = MockLine(move_type='in_refund') line.quantity = 5.0 line.price_unit = 100.0 # Set up 10% tax tax_10 = MockTax('Tax 10%', 10.0, price_include=False) line.set_taxes([tax_10]) # Set negative price_total line.price_total = -550.0 line._onchange_price_total() expected_price_unit = -100.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated negative price_unit with tax: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_no_taxes_price_total_equals_subtotal(): """ Test that when no taxes are configured, price_total is treated as price_subtotal. Requirement 3.3: Handle no taxes scenario correctly """ line = MockLine() line.quantity = 10.0 line.price_unit = 100.0 line.price_total = 1000.0 line._onchange_price_total() expected_price_unit = 100.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly handled no taxes scenario: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_single_tax_calculation(): """ Test that a single tax is correctly computed in price_total calculation. Requirement 3.4: Correctly compute single tax effect """ line = MockLine() line.quantity = 10.0 line.price_unit = 100.0 # Set up 10% tax tax_10 = MockTax('Tax 10%', 10.0, price_include=False) line.set_taxes([tax_10]) # Set price_total with 10% tax line.price_total = 1100.0 line._onchange_price_total() expected_price_unit = 100.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated with single tax: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_single_tax_included_calculation(): """ Test that a single tax-included tax is correctly computed. Requirement 3.4: Correctly compute single tax effect with price_include=True """ line = MockLine() line.quantity = 10.0 line.price_unit = 100.0 # Set up 15% tax-included tax_15_included = MockTax('Tax 15% Included', 15.0, price_include=True) line.set_taxes([tax_15_included]) # Set price_total with 15% tax-included line.price_total = 1150.0 line._onchange_price_total() expected_price_unit = 115.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated with tax-included: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_multiple_taxes_calculation(): """ Test that multiple taxes are correctly computed in price_total calculation. Requirement 3.4: Correctly compute combined tax effect """ line = MockLine() line.quantity = 10.0 line.price_unit = 100.0 # Set up 10% + 5% taxes tax_10 = MockTax('Tax 10%', 10.0, price_include=False) tax_5 = MockTax('Tax 5%', 5.0, price_include=False) line.set_taxes([tax_10, tax_5]) # Set price_total with 15% combined tax line.price_total = 1150.0 line._onchange_price_total() expected_price_unit = 100.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated with multiple taxes: {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" def test_multiple_taxes_with_different_amounts(): """ Test multiple taxes with different amounts to verify correct computation. Requirement 3.4: Correctly compute combined tax effect with various amounts """ line = MockLine() line.quantity = 5.0 line.price_unit = 200.0 # Set up 10% + 5% taxes tax_10 = MockTax('Tax 10%', 10.0, price_include=False) tax_5 = MockTax('Tax 5%', 5.0, price_include=False) line.set_taxes([tax_10, tax_5]) # Set price_total with 15% combined tax line.price_total = 1150.0 line._onchange_price_total() expected_price_unit = 200.0 precision = 2 if abs(line.price_unit - expected_price_unit) < 10 ** (-precision): return True, f"Correctly calculated with multiple taxes (different amounts): {line.price_unit}" else: return False, f"Expected {expected_price_unit}, got {line.price_unit}" if __name__ == '__main__': print("Running Edge Case Unit Tests for vendor-bill-editable-totals") print("=" * 70) tests = [ ("Zero quantity with price_subtotal", test_zero_quantity_price_subtotal_raises_error), ("Zero quantity with price_total", test_zero_quantity_price_total_raises_error), ("Negative price_subtotal (credit note)", test_negative_price_subtotal_credit_note), ("Negative price_total (credit note)", test_negative_price_total_credit_note), ("No taxes scenario", test_no_taxes_price_total_equals_subtotal), ("Single tax calculation", test_single_tax_calculation), ("Single tax-included calculation", test_single_tax_included_calculation), ("Multiple taxes calculation", test_multiple_taxes_calculation), ("Multiple taxes with different amounts", test_multiple_taxes_with_different_amounts), ] passed = 0 failed = 0 for test_name, test_func in tests: print(f"\nTest: {test_name}") print("-" * 70) try: success, message = test_func() if success: print(f"✓ PASSED: {message}") passed += 1 else: print(f"✗ FAILED: {message}") failed += 1 except Exception as e: print(f"✗ FAILED with exception: {e}") failed += 1 print("\n" + "=" * 70) print(f"Results: {passed} passed, {failed} failed out of {len(tests)} tests") if failed == 0: print("✓ All edge case tests passed!") sys.exit(0) else: print("✗ Some edge case tests failed!") sys.exit(1)