From 48caecffdad70fefa17ad15f19b4479ff4ed5bf0 Mon Sep 17 00:00:00 2001 From: Sandeep Tuniki Date: Thu, 23 Apr 2026 12:14:36 +0530 Subject: [PATCH 1/2] Add counter-based validations and tests --- tools/import_validation/runner.py | 38 +++- tools/import_validation/runner_test.py | 54 +++++ tools/import_validation/validator.py | 240 ++++++++++++++++++++++ tools/import_validation/validator_test.py | 151 ++++++++++++++ 4 files changed, 478 insertions(+), 5 deletions(-) diff --git a/tools/import_validation/runner.py b/tools/import_validation/runner.py index 9dcc3cbe17..720a2b37fd 100644 --- a/tools/import_validation/runner.py +++ b/tools/import_validation/runner.py @@ -40,7 +40,8 @@ class ValidationRunner: """ def __init__(self, validation_config_path: str, differ_output: str, - stats_summary: str, lint_report: str, validation_output: str): + stats_summary: str, lint_report: str, validation_output: str, + counters_report: str = None): self.config = ValidationConfig(validation_config_path) self.validation_output = validation_output self.validator = Validator() @@ -49,7 +50,8 @@ def __init__(self, validation_config_path: str, differ_output: str, 'stats': pd.DataFrame(), 'differ': pd.DataFrame(), 'differ_summary': {}, - 'lint': {} + 'lint': {}, + 'counters': {} } self.validation_dispatch = { @@ -85,12 +87,22 @@ def __init__(self, validation_config_path: str, differ_output: str, 'MAX_VALUE_CHECK': (self.validator.validate_max_value_check, 'stats'), 'GOLDENS_CHECK': (self.validator.validate_goldens, 'stats'), + 'COUNTER_ZERO_CHECK': + (self.validator.validate_counter_zero, 'counters'), + 'COUNTER_MAX_THRESHOLD': + (self.validator.validate_counter_max_threshold, 'counters'), + 'COUNTER_RATIO_THRESHOLD': + (self.validator.validate_counter_ratio_threshold, 'counters'), + 'COUNTER_SUM_INTEGRITY': + (self.validator.validate_counter_sum_integrity, 'counters'), + 'COUNTER_MIN_YIELD': + (self.validator.validate_counter_min_yield, 'counters'), } - self._initialize_data_sources(stats_summary, lint_report, differ_output) + self._initialize_data_sources(stats_summary, lint_report, differ_output, counters_report) def _initialize_data_sources(self, stats_summary: str, lint_report: str, - differ_output: str): + differ_output: str, counters_report: str = None): """ Checks for and loads the required data sources based on the config. """ @@ -167,6 +179,19 @@ def _initialize_data_sources(self, stats_summary: str, lint_report: str, logging.warning("lint_report file exists but is empty: %s", lint_report) + if counters_report and os.path.exists(counters_report) and os.path.getsize( + counters_report) > 0: + try: + with open(counters_report, 'r') as f: + self.data_sources['counters'] = json.load(f) + except Exception as e: + logging.error( + f"JSON parse error while reading counters report at {counters_report}: {e}" + ) + elif counters_report and os.path.exists(counters_report): + logging.warning("counters_report file exists but is empty: %s", + counters_report) + def _determine_required_sources(self) -> set[str]: """ Parses the validation config to determine which data sources are required. @@ -265,7 +290,8 @@ def main(_): differ_output=_FLAGS.differ_output, stats_summary=_FLAGS.stats_summary, lint_report=_FLAGS.lint_report, - validation_output=_FLAGS.validation_output) + validation_output=_FLAGS.validation_output, + counters_report=_FLAGS.counters_report) overall_status, _ = runner.run_validations() if not overall_status: sys.exit(1) @@ -283,6 +309,8 @@ def main(_): 'Path to the stats summary report file.') flags.DEFINE_string('lint_report', None, 'Path to the mcf lint report file.') + flags.DEFINE_string('counters_report', None, + 'Path to the counters report file.') flags.DEFINE_string('validation_output', None, 'Path to the validation output file.') flags.mark_flag_as_required('validation_output') diff --git a/tools/import_validation/runner_test.py b/tools/import_validation/runner_test.py index 76c5bc1d0b..e6621a4014 100644 --- a/tools/import_validation/runner_test.py +++ b/tools/import_validation/runner_test.py @@ -350,3 +350,57 @@ def test_init_raises_error_if_required_file_is_missing(self): lint_report=self.report_path, validation_output=self.output_path) self.assertIn("'stats' data source", str(context.exception)) + + +class TestCountersIntegration(unittest.TestCase): + '''Test Class for counter validations integration in ValidationRunner.''' + + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self.test_dir.name, 'config.json') + self.stats_path = os.path.join(self.test_dir.name, 'stats.csv') + self.report_path = os.path.join(self.test_dir.name, 'report.json') + self.differ_path = os.path.join(self.test_dir.name, 'differ.csv') + self.output_path = os.path.join(self.test_dir.name, 'output.csv') + self.counters_path = os.path.join(self.test_dir.name, 'counters.json') + + def tearDown(self): + self.test_dir.cleanup() + + @patch('tools.import_validation.runner.Validator') + def test_runner_loads_counters_and_calls_validator(self, MockValidator): + # 1. Setup the mock + mock_validator_instance = MockValidator.return_value + mock_validator_instance.validate_counter_zero.return_value = ValidationResult( + ValidationStatus.PASSED, 'COUNTER_ZERO_CHECK') + + # 2. Create test files + with open(self.config_path, 'w') as f: + json.dump( + { + 'rules': [{ + 'rule_id': 'check_zero_errors', + 'validator': 'COUNTER_ZERO_CHECK', + 'params': {'counter_name': 'invalid-lat-lng'} + }] + }, f) + + counters_data = {'invalid-lat-lng': 0} + with open(self.counters_path, 'w') as f: + json.dump(counters_data, f) + + # 3. Run the runner + runner = ValidationRunner( + validation_config_path=self.config_path, + stats_summary=self.stats_path, + differ_output=self.differ_path, + lint_report=self.report_path, + validation_output=self.output_path, + counters_report=self.counters_path) + runner.run_validations() + + # 4. Assert that the correct method was called on the mock with loaded counters + mock_validator_instance.validate_counter_zero.assert_called_once() + call_args, _ = mock_validator_instance.validate_counter_zero.call_args + self.assertEqual(call_args[0]['invalid-lat-lng'], 0) + diff --git a/tools/import_validation/validator.py b/tools/import_validation/validator.py index e16e13555a..365e708600 100644 --- a/tools/import_validation/validator.py +++ b/tools/import_validation/validator.py @@ -1031,3 +1031,243 @@ def validate_goldens(self, df: pd.DataFrame, ValidationStatus.DATA_ERROR, 'GOLDENS_CHECK', message=f"Error during golden validation: {e}") + + def validate_counter_zero(self, counters: dict, + params: dict) -> ValidationResult: + """Checks that a specific counter is strictly zero. + + Args: + counters: A dictionary containing the counter values. + params: A dictionary containing the validation parameters, which must + have a 'counter_name' key. + + Returns: + A ValidationResult object. + """ + if 'counter_name' not in params: + return ValidationResult( + ValidationStatus.CONFIG_ERROR, + 'COUNTER_ZERO_CHECK', + message="Configuration error: 'counter_name' key not specified." + ) + + counter_name = params['counter_name'] + value = counters.get(counter_name, 0) + + if value > 0: + return ValidationResult( + ValidationStatus.FAILED, + 'COUNTER_ZERO_CHECK', + message=f"Counter '{counter_name}' is {value}, expected 0.", + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'expected_value': 0 + }) + + return ValidationResult(ValidationStatus.PASSED, + 'COUNTER_ZERO_CHECK', + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'expected_value': 0 + }) + + def validate_counter_max_threshold(self, counters: dict, + params: dict) -> ValidationResult: + """Checks that a specific counter value does not exceed a maximum threshold. + + Args: + counters: A dictionary containing the counter values. + params: A dictionary containing the validation parameters, which must + have 'counter_name' and 'threshold' keys. + + Returns: + A ValidationResult object. + """ + if 'counter_name' not in params or 'threshold' not in params: + return ValidationResult( + ValidationStatus.CONFIG_ERROR, + 'COUNTER_MAX_THRESHOLD', + message= + "Configuration error: 'counter_name' and 'threshold' must be specified." + ) + + counter_name = params['counter_name'] + threshold = params['threshold'] + value = counters.get(counter_name, 0) + + if value > threshold: + return ValidationResult( + ValidationStatus.FAILED, + 'COUNTER_MAX_THRESHOLD', + message= + f"Counter '{counter_name}' is {value}, which exceeds the threshold of {threshold}.", + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'threshold': threshold + }) + + return ValidationResult(ValidationStatus.PASSED, + 'COUNTER_MAX_THRESHOLD', + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'threshold': threshold + }) + + def validate_counter_ratio_threshold(self, counters: dict, + params: dict) -> ValidationResult: + """Checks that the ratio of two counters (percentage) does not exceed a threshold. + + Args: + counters: A dictionary containing the counter values. + params: A dictionary containing the validation parameters, which must + have 'subset_counter', 'total_counter', and 'threshold_percent' keys. + + Returns: + A ValidationResult object. + """ + if 'subset_counter' not in params or 'total_counter' not in params or 'threshold_percent' not in params: + return ValidationResult( + ValidationStatus.CONFIG_ERROR, + 'COUNTER_RATIO_THRESHOLD', + message= + "Configuration error: 'subset_counter', 'total_counter', and 'threshold_percent' must be specified." + ) + + subset_counter = params['subset_counter'] + total_counter = params['total_counter'] + threshold_percent = params['threshold_percent'] + + subset_value = counters.get(subset_counter, 0) + total_value = counters.get(total_counter, 0) + + if total_value == 0: + if subset_value > 0: + percent = 100.0 + else: + percent = 0.0 + else: + percent = (subset_value / total_value) * 100 + + if percent > threshold_percent: + return ValidationResult( + ValidationStatus.FAILED, + 'COUNTER_RATIO_THRESHOLD', + message= + f"Ratio of '{subset_counter}' to '{total_counter}' is {percent:.2f}%, which exceeds the threshold of {threshold_percent}%.", + details={ + 'subset_counter': subset_counter, + 'subset_value': subset_value, + 'total_counter': total_counter, + 'total_value': total_value, + 'percent': percent, + 'threshold_percent': threshold_percent + }) + + return ValidationResult(ValidationStatus.PASSED, + 'COUNTER_RATIO_THRESHOLD', + details={ + 'subset_counter': subset_counter, + 'subset_value': subset_value, + 'total_counter': total_counter, + 'total_value': total_value, + 'percent': percent, + 'threshold_percent': threshold_percent + }) + + def validate_counter_sum_integrity(self, counters: dict, + params: dict) -> ValidationResult: + """Checks that the sum of constituent counters equals a total counter. + + Args: + counters: A dictionary containing the counter values. + params: A dictionary containing the validation parameters, which must + have 'total_counter' and 'constituent_counters' keys. + + Returns: + A ValidationResult object. + """ + if 'total_counter' not in params or 'constituent_counters' not in params: + return ValidationResult( + ValidationStatus.CONFIG_ERROR, + 'COUNTER_SUM_INTEGRITY', + message= + "Configuration error: 'total_counter' and 'constituent_counters' must be specified." + ) + + total_counter = params['total_counter'] + constituent_counters = params['constituent_counters'] + + total_value = counters.get(total_counter, 0) + constituent_sum = sum( + counters.get(c, 0) for c in constituent_counters) + + if total_value != constituent_sum: + return ValidationResult( + ValidationStatus.FAILED, + 'COUNTER_SUM_INTEGRITY', + message= + f"Sum of constituent counters ({constituent_sum}) does not equal total counter '{total_counter}' ({total_value}).", + details={ + 'total_counter': total_counter, + 'total_value': total_value, + 'constituent_counters': constituent_counters, + 'constituent_sum': constituent_sum + }) + + return ValidationResult(ValidationStatus.PASSED, + 'COUNTER_SUM_INTEGRITY', + details={ + 'total_counter': total_counter, + 'total_value': total_value, + 'constituent_counters': constituent_counters, + 'constituent_sum': constituent_sum + }) + + def validate_counter_min_yield(self, counters: dict, + params: dict) -> ValidationResult: + """Checks that a specific counter is above a minimum yield. + + Args: + counters: A dictionary containing the counter values. + params: A dictionary containing the validation parameters, which must + have 'counter_name' and 'min_yield' keys. + + Returns: + A ValidationResult object. + """ + if 'counter_name' not in params or 'min_yield' not in params: + return ValidationResult( + ValidationStatus.CONFIG_ERROR, + 'COUNTER_MIN_YIELD', + message= + "Configuration error: 'counter_name' and 'min_yield' must be specified." + ) + + counter_name = params['counter_name'] + min_yield = params['min_yield'] + value = counters.get(counter_name, 0) + + if value < min_yield: + return ValidationResult( + ValidationStatus.FAILED, + 'COUNTER_MIN_YIELD', + message= + f"Counter '{counter_name}' is {value}, which is below the minimum yield of {min_yield}.", + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'min_yield': min_yield + }) + + return ValidationResult(ValidationStatus.PASSED, + 'COUNTER_MIN_YIELD', + details={ + 'counter_name': counter_name, + 'actual_value': value, + 'min_yield': min_yield + }) + diff --git a/tools/import_validation/validator_test.py b/tools/import_validation/validator_test.py index 124f61d8b1..e56e9efee5 100644 --- a/tools/import_validation/validator_test.py +++ b/tools/import_validation/validator_test.py @@ -903,5 +903,156 @@ def test_validate_goldens_missing_golden_files_param(self): self.assertIn('golden_files', result.message) +class TestCounterZeroValidation(unittest.TestCase): + '''Test Class for the COUNTER_ZERO_CHECK validation rule.''' + + def setUp(self): + self.validator = Validator() + + def test_counter_zero_fails_when_positive(self): + counters = {'invalid-lat-lng': 5} + params = {'counter_name': 'invalid-lat-lng'} + result = self.validator.validate_counter_zero(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['actual_value'], 5) + + def test_counter_zero_passes_when_zero(self): + counters = {'invalid-lat-lng': 0} + params = {'counter_name': 'invalid-lat-lng'} + result = self.validator.validate_counter_zero(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + def test_counter_zero_passes_when_missing(self): + counters = {} + params = {'counter_name': 'invalid-lat-lng'} + result = self.validator.validate_counter_zero(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + def test_counter_zero_fails_on_missing_config(self): + counters = {'invalid-lat-lng': 0} + params = {} + result = self.validator.validate_counter_zero(counters, params) + self.assertEqual(result.status, ValidationStatus.CONFIG_ERROR) + + +class TestCounterMaxThresholdValidation(unittest.TestCase): + '''Test Class for the COUNTER_MAX_THRESHOLD validation rule.''' + + def setUp(self): + self.validator = Validator() + + def test_counter_max_threshold_fails_when_over(self): + counters = {'dropped_points': 10} + params = {'counter_name': 'dropped_points', 'threshold': 5} + result = self.validator.validate_counter_max_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['actual_value'], 10) + + def test_counter_max_threshold_passes_when_at_threshold(self): + counters = {'dropped_points': 5} + params = {'counter_name': 'dropped_points', 'threshold': 5} + result = self.validator.validate_counter_max_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + def test_counter_max_threshold_passes_when_below(self): + counters = {'dropped_points': 2} + params = {'counter_name': 'dropped_points', 'threshold': 5} + result = self.validator.validate_counter_max_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + def test_counter_max_threshold_fails_on_missing_config(self): + counters = {'dropped_points': 2} + params = {'counter_name': 'dropped_points'} # Missing threshold + result = self.validator.validate_counter_max_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.CONFIG_ERROR) + + +class TestCounterRatioThresholdValidation(unittest.TestCase): + '''Test Class for the COUNTER_RATIO_THRESHOLD validation rule.''' + + def setUp(self): + self.validator = Validator() + + def test_counter_ratio_threshold_fails_when_over(self): + counters = {'processed_points_dropped': 20, 'total_points': 100} + params = { + 'subset_counter': 'processed_points_dropped', + 'total_counter': 'total_points', + 'threshold_percent': 10.0 + } + result = self.validator.validate_counter_ratio_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['percent'], 20.0) + + def test_counter_ratio_threshold_passes_when_below(self): + counters = {'processed_points_dropped': 5, 'total_points': 100} + params = { + 'subset_counter': 'processed_points_dropped', + 'total_counter': 'total_points', + 'threshold_percent': 10.0 + } + result = self.validator.validate_counter_ratio_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + self.assertEqual(result.details['percent'], 5.0) + + def test_counter_ratio_threshold_handles_zero_total(self): + counters = {'processed_points_dropped': 5, 'total_points': 0} + params = { + 'subset_counter': 'processed_points_dropped', + 'total_counter': 'total_points', + 'threshold_percent': 10.0 + } + result = self.validator.validate_counter_ratio_threshold(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['percent'], 100.0) + + +class TestCounterSumIntegrityValidation(unittest.TestCase): + '''Test Class for the COUNTER_SUM_INTEGRITY validation rule.''' + + def setUp(self): + self.validator = Validator() + + def test_counter_sum_integrity_fails_on_mismatch(self): + counters = {'total_points': 100, 'output_points': 80, 'dropped_points': 10} + params = { + 'total_counter': 'total_points', + 'constituent_counters': ['output_points', 'dropped_points'] + } + result = self.validator.validate_counter_sum_integrity(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['constituent_sum'], 90) + self.assertEqual(result.details['total_value'], 100) + + def test_counter_sum_integrity_passes_on_match(self): + counters = {'total_points': 100, 'output_points': 90, 'dropped_points': 10} + params = { + 'total_counter': 'total_points', + 'constituent_counters': ['output_points', 'dropped_points'] + } + result = self.validator.validate_counter_sum_integrity(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + +class TestCounterMinYieldValidation(unittest.TestCase): + '''Test Class for the COUNTER_MIN_YIELD validation rule.''' + + def setUp(self): + self.validator = Validator() + + def test_counter_min_yield_fails_below_minimum(self): + counters = {'output_points': 50} + params = {'counter_name': 'output_points', 'min_yield': 100} + result = self.validator.validate_counter_min_yield(counters, params) + self.assertEqual(result.status, ValidationStatus.FAILED) + self.assertEqual(result.details['actual_value'], 50) + + def test_counter_min_yield_passes_at_minimum(self): + counters = {'output_points': 100} + params = {'counter_name': 'output_points', 'min_yield': 100} + result = self.validator.validate_counter_min_yield(counters, params) + self.assertEqual(result.status, ValidationStatus.PASSED) + + if __name__ == '__main__': unittest.main() From b3dea6b58e53d8be2c3e60401cdbbf5dce044c1b Mon Sep 17 00:00:00 2001 From: Sandeep Tuniki Date: Thu, 23 Apr 2026 13:45:00 +0530 Subject: [PATCH 2/2] Update counters integration to use string split and fix missing import --- tools/import_validation/runner.py | 28 ++++++++++--- tools/import_validation/runner_test.py | 56 ++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/tools/import_validation/runner.py b/tools/import_validation/runner.py index 720a2b37fd..e818f0fa52 100644 --- a/tools/import_validation/runner.py +++ b/tools/import_validation/runner.py @@ -179,16 +179,34 @@ def _initialize_data_sources(self, stats_summary: str, lint_report: str, logging.warning("lint_report file exists but is empty: %s", lint_report) - if counters_report and os.path.exists(counters_report) and os.path.getsize( + if counters_report: + self._load_counters(counters_report) + + def _load_counters(self, counters_report: str): + """Loads counters from a CSV file and stores them in data_sources.""" + if os.path.exists(counters_report) and os.path.getsize( counters_report) > 0: try: - with open(counters_report, 'r') as f: - self.data_sources['counters'] = json.load(f) + df = pd.read_csv(counters_report) + def clean_key(x): + if not isinstance(x, str): + return x + if ':' in x: + x = x.split(':', 1)[1] + if '_' in x: + x = x.rsplit('_', 1)[-1] + return x + + df['key'] = df['key'].apply(clean_key) + # Aggregate by summing if there are duplicates + df = df.groupby('key')['value'].sum().reset_index() + self.data_sources['counters'] = dict( + zip(df['key'], df['value'])) except Exception as e: logging.error( - f"JSON parse error while reading counters report at {counters_report}: {e}" + f"CSV parse error while reading counters report at {counters_report}: {e}" ) - elif counters_report and os.path.exists(counters_report): + elif os.path.exists(counters_report): logging.warning("counters_report file exists but is empty: %s", counters_report) diff --git a/tools/import_validation/runner_test.py b/tools/import_validation/runner_test.py index e6621a4014..1c06ff010d 100644 --- a/tools/import_validation/runner_test.py +++ b/tools/import_validation/runner_test.py @@ -13,6 +13,7 @@ # limitations under the License. """Tests for the ValidationRunner.""" +import csv import unittest from unittest.mock import patch, MagicMock import pandas as pd @@ -362,7 +363,7 @@ def setUp(self): self.report_path = os.path.join(self.test_dir.name, 'report.json') self.differ_path = os.path.join(self.test_dir.name, 'differ.csv') self.output_path = os.path.join(self.test_dir.name, 'output.csv') - self.counters_path = os.path.join(self.test_dir.name, 'counters.json') + self.counters_path = os.path.join(self.test_dir.name, 'counters.csv') def tearDown(self): self.test_dir.cleanup() @@ -385,9 +386,11 @@ def test_runner_loads_counters_and_calls_validator(self, MockValidator): }] }, f) - counters_data = {'invalid-lat-lng': 0} - with open(self.counters_path, 'w') as f: - json.dump(counters_data, f) + # Create sample CSV data for counters + with open(self.counters_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['key', 'value']) + writer.writerow(['invalid-lat-lng', 0]) # 3. Run the runner runner = ValidationRunner( @@ -404,3 +407,48 @@ def test_runner_loads_counters_and_calls_validator(self, MockValidator): call_args, _ = mock_validator_instance.validate_counter_zero.call_args self.assertEqual(call_args[0]['invalid-lat-lng'], 0) + @patch('tools.import_validation.runner.Validator') + def test_runner_strips_counter_prefixes(self, MockValidator): + # 1. Setup the mock + mock_validator_instance = MockValidator.return_value + mock_validator_instance.validate_counter_max_threshold.return_value = ValidationResult( + ValidationStatus.PASSED, 'COUNTER_MAX_THRESHOLD') + + # 2. Create test files with prefixed keys + with open(self.config_path, 'w') as f: + json.dump( + { + 'rules': [{ + 'rule_id': 'check_dropped_points', + 'validator': 'COUNTER_MAX_THRESHOLD', + 'params': { + 'counter_name': 'dropped-points', + 'threshold': 10 + } + }] + }, f) + + # Create sample CSV data with prefixes and duplicates + with open(self.counters_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['key', 'value']) + writer.writerow(['2:prepare_output_dropped-points', 5]) + writer.writerow(['3:write_statvar_mcf_dropped-points', 3]) + + # 3. Run the runner + runner = ValidationRunner( + validation_config_path=self.config_path, + stats_summary=self.stats_path, + differ_output=self.differ_path, + lint_report=self.report_path, + validation_output=self.output_path, + counters_report=self.counters_path) + runner.run_validations() + + # 4. Assert that the correct method was called with aggregated counters + mock_validator_instance.validate_counter_max_threshold.assert_called_once() + call_args, _ = mock_validator_instance.validate_counter_max_threshold.call_args + # Values should be summed: 5 + 3 = 8 + self.assertEqual(call_args[0]['dropped-points'], 8) + +