"""Tests for per-row rounding in the validation total. Verifies that the validation lambda computes totals using the same per-row to_cent() rounding as the import lambda, so the UI confirmation total matches what actually gets imported. See ACC-10303 for context on the original rounding mismatch. """ from decimal import Decimal from unittest.mock import MagicMock, patch import numpy as np import pytest from abacus_common_logic.utils.decimals import to_cent from adjustment_file_validation.adjustment_file_validation import ( AdjustmentFileValidation, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- TEMP_FILE = '/tmp/test_rounding.xlsx' FILE_ID = 1 PERIOD_ID = 1 def _make_row(amount, **overrides): """Build a minimal adjustment row dict.""" base = { 'account_id': '100', 'contract_id': '200', 'upc': None, 'amount': amount, 'currency': 'USD', 'activity_year': '2026', 'activity_month': '1', 'statement_year': '2026', 'statement_month': '1', 'adjustment_type': 'Adjustment', 'client_facing_comments': 'test', 'distribution_type': None, 'internal_note': None, } base.update(overrides) return base def _run_validation(rows): """Run read_and_validate_file against a list of row dicts. Returns (total_file_amount, rounded_amount). """ with ( patch( 'adjustment_file_validation.adjustment_file_validation' '.validate_with_shared_validation', return_value={}, ), patch( 'adjustment_file_validation.adjustment_file_validation.openpyxl' ) as mock_openpyxl, patch( 'adjustment_file_validation.adjustment_file_validation.pd' ) as mock_pandas, ): # Wire up the pandas mock chain chain = mock_pandas.read_excel.return_value.astype.return_value.replace.return_value.dropna.return_value.astype.return_value.where.return_value.apply.return_value chain.iterrows = MagicMock(return_value=enumerate(rows)) chain.empty = False chain.shape = [len(rows), 4] chain.columns = list(range(16)) wb = mock_openpyxl.workbook() mock_openpyxl.load_workbook = MagicMock(return_value=wb) afv = AdjustmentFileValidation(TEMP_FILE, FILE_ID, PERIOD_ID) ( _valid, _invalid, total_file_amount, rounded_amount, _error, _error_type, ) = afv.read_and_validate_file() return total_file_amount, rounded_amount # --------------------------------------------------------------------------- # to_cent parity — validation total must equal import total # --------------------------------------------------------------------------- class TestToCentParity: """Validation total must equal the sum of per-row to_cent() values.""" def test_single_row_matches_to_cent(self): """A single row's contribution should equal to_cent(amount).""" amount = '-50.8979990' total, rounded = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert rounded == to_cent(amount) def test_multi_row_sum_matches_per_row_to_cent(self): """Sum of individually rounded amounts, not round-of-sum.""" amounts = ['-50.8979990', '100.123', '-0.001'] rows = [_make_row(a) for a in amounts] total, rounded = _run_validation(rows) expected = sum(to_cent(a) for a in amounts) assert total == expected assert rounded == expected def test_demonstrates_old_vs_new_difference(self): """Show that the old approach (round-of-sum) would differ. This is the exact scenario from ACC-10303: per-row ROUND_UP accumulates differently than rounding the raw sum once. """ # Each amount has a fractional cent that ROUND_UP pushes to the next cent. # With 1000 rows of $1.001, per-row rounding gives $1.01 * 1000 = $1010.00, # but rounding the raw sum gives ROUND_UP($1001.00) = $1001.00 — a $9.00 gap. amounts = ['1.001'] * 1000 rows = [_make_row(a) for a in amounts] total, _ = _run_validation(rows) per_row_sum = sum(to_cent(a) for a in amounts) raw_sum = sum(Decimal(a) for a in amounts) assert total == per_row_sum assert total == Decimal('1010.00') # The old approach would have produced 1001.00 — confirm it differs. assert raw_sum == Decimal('1001.000') assert total != raw_sum def test_large_batch_drift(self): """Simulate drift at scale (like the 61K-row batch from ACC-10303).""" amounts = ['-1.143'] * 500 rows = [_make_row(a) for a in amounts] total, rounded = _run_validation(rows) expected = sum(to_cent(a) for a in amounts) assert total == expected assert rounded == expected # Verify the actual value: to_cent('-1.143') = -1.15, * 500 = -575.00 assert total == Decimal('-575.00') # --------------------------------------------------------------------------- # Floating-point edge cases # --------------------------------------------------------------------------- class TestFloatingPointEdgeCases: """Guard against floating-point representation issues.""" def test_binary_fraction_drift(self): """0.1 + 0.2 != 0.3 in IEEE 754; Decimal avoids this.""" amounts = ['0.1', '0.2'] total, _ = _run_validation([_make_row(a) for a in amounts]) expected = to_cent('0.1') + to_cent('0.2') assert total == expected assert total == Decimal('0.30') def test_amount_with_many_decimal_places(self): """Amounts with high precision should round cleanly.""" amount = '123.456789012345' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert total == Decimal('123.46') def test_negative_amount_with_many_decimal_places(self): """Negative high-precision amount rounds away from zero.""" amount = '-99.9999999' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) # ROUND_UP rounds away from zero: -99.9999999 → -100.00 assert total == Decimal('-100.00') def test_half_cent_boundary(self): """Exactly 0.5 cents — ROUND_UP rounds away from zero.""" amount = '1.005' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert total == Decimal('1.01') def test_negative_half_cent_boundary(self): """Negative half-cent boundary rounds away from zero.""" amount = '-1.005' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert total == Decimal('-1.01') def test_sub_cent_amount(self): """An amount smaller than one cent still rounds to one cent.""" amount = '0.001' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert total == Decimal('0.01') def test_exact_cents_unchanged(self): """Amounts already in whole cents should pass through unchanged.""" amounts = ['100.00', '-50.50', '0.01'] total, _ = _run_validation([_make_row(a) for a in amounts]) expected = sum(Decimal(a) for a in amounts) assert total == expected assert total == Decimal('49.51') # --------------------------------------------------------------------------- # Boundary and special values # --------------------------------------------------------------------------- class TestBoundaryValues: """Edge cases around zero, sign, and invalid amounts.""" def test_zero_amount(self): """Zero amount contributes 0.00.""" total, _ = _run_validation([_make_row('0')]) assert total == Decimal('0.00') def test_negative_zero(self): """Negative zero normalizes to 0.00.""" total, _ = _run_validation([_make_row('-0.00')]) assert total == Decimal('0.00') def test_positive_and_negative_cancel_out(self): """Symmetric positive and negative amounts should sum to zero.""" amounts = ['100.50', '-100.50'] total, _ = _run_validation([_make_row(a) for a in amounts]) assert total == Decimal('0.00') def test_mixed_sign_rounding(self): """ROUND_UP rounds +0.001 to +0.01, -0.001 to -0.01 (away from zero).""" amounts = ['0.001', '-0.001'] total, _ = _run_validation([_make_row(a) for a in amounts]) # Each rounds away from zero, but the signs cancel assert total == Decimal('0.00') def test_invalid_amount_treated_as_zero(self): """Non-numeric amounts contribute 0.00 to the total.""" rows = [_make_row('100.00'), _make_row('not_a_number')] total, _ = _run_validation(rows) assert total == Decimal('100.00') def test_nan_amount_treated_as_zero(self): """NaN amounts contribute 0.00 to the total.""" rows = [_make_row('50.00'), _make_row(str(np.nan))] total, _ = _run_validation(rows) assert total == Decimal('50.00') def test_very_large_amount(self): """Large amounts round correctly.""" amount = '999999999.99' total, _ = _run_validation([_make_row(amount)]) assert total == Decimal('999999999.99') def test_very_small_fractional(self): """Near-zero positive amount rounds up to one cent.""" amount = '0.000000001' total, _ = _run_validation([_make_row(amount)]) assert total == to_cent(amount) assert total == Decimal('0.01') # --------------------------------------------------------------------------- # Return value contract # --------------------------------------------------------------------------- class TestReturnValueContract: """total_file_amount and rounded_amount must always be equal.""" @pytest.mark.parametrize( 'amounts', [ ['100.00'], ['-50.897999'], ['1.001', '2.002', '3.003'], ['0.001'] * 100, ['-999.999', '999.999'], ], ids=[ 'exact_cents', 'single_fractional', 'three_fractional_rows', 'hundred_sub_cent_rows', 'symmetric_cancellation', ], ) def test_total_equals_rounded(self, amounts): """total_file_amount and rounded_amount are always equal.""" rows = [_make_row(a) for a in amounts] total, rounded = _run_validation(rows) assert total == rounded, ( f'total_file_amount ({total}) must equal rounded_amount ({rounded})' )