From 82cd311bb479950ca1cfd66237c522d9ac985bb0 Mon Sep 17 00:00:00 2001 From: Embedded DevOps Date: Tue, 19 Nov 2024 09:57:01 -0800 Subject: [PATCH] Initial commit of rebased branch coverage. --- cover_agent/CoverageProcessor.py | 139 ++++++++++---- cover_agent/UnitTestValidator.py | 17 +- .../c_cli/build_and_test_with_coverage.sh | 6 +- .../cpp_cli/build_and_test_with_coverage.sh | 6 +- tests/test_CoverageProcessor.py | 180 +++++++++++++++--- tests/test_UnitTestValidator.py | 22 ++- tests_integration/test_all.sh | 2 +- 7 files changed, 288 insertions(+), 84 deletions(-) diff --git a/cover_agent/CoverageProcessor.py b/cover_agent/CoverageProcessor.py index 13063fd2b..3959c62c4 100644 --- a/cover_agent/CoverageProcessor.py +++ b/cover_agent/CoverageProcessor.py @@ -107,20 +107,20 @@ def parse_coverage_report(self) -> Tuple[list, list, float]: else: raise ValueError(f"Unsupported coverage report type: {self.coverage_type}") - def parse_coverage_report_cobertura(self, filename: str = None) -> Union[Tuple[list, list, float], dict]: + def parse_coverage_report_cobertura(self, filename: str = None) -> Union[Tuple[list, list, float, float], dict]: """ Parses a Cobertura XML code coverage report to extract covered and missed line numbers for a specific file - or all files, and calculates the coverage percentage. + or all files, and calculates the line and branch coverage percentages. Args: filename (str, optional): The name of the file to process. If None, processes all files. Returns: - Union[Tuple[list, list, float], dict]: If filename is provided, returns a tuple - containing lists of covered and missed line numbers, and the coverage percentage. - If filename is None, returns a dictionary with filenames as keys and a tuple - containing lists of covered and missed line numbers, and the coverage percentage - as values. + Union[Tuple[list, list, float, float], dict]: If filename is provided, returns a tuple + containing lists of covered and missed line numbers, the line coverage percentage, + and the branch coverage percentage. If filename is None, returns a dictionary with + filenames as keys and a tuple containing lists of covered and missed line numbers, + the line coverage percentage, and the branch coverage percentage as values. """ tree = ET.parse(self.file_path) root = tree.getroot() @@ -130,14 +130,14 @@ def parse_coverage_report_cobertura(self, filename: str = None) -> Union[Tuple[l name_attr = cls.get("filename") if name_attr and name_attr.endswith(filename): return self.parse_coverage_data_for_class(cls) - return [], [], 0.0 # Return empty lists if the file is not found + return [], [], 0.0, 0.0 # Return empty lists if the file is not found else: coverage_data = {} for cls in root.findall(".//class"): cls_filename = cls.get("filename") if cls_filename: - lines_covered, lines_missed, coverage_percentage = self.parse_coverage_data_for_class(cls) - coverage_data[cls_filename] = (lines_covered, lines_missed, coverage_percentage) + lines_covered, lines_missed, coverage_percentage, branch_coverage_percentage = self.parse_coverage_data_for_class(cls) + coverage_data[cls_filename] = (lines_covered, lines_missed, coverage_percentage, branch_coverage_percentage) return coverage_data def parse_coverage_data_for_class(self, cls) -> Tuple[list, list, float]: @@ -148,10 +148,13 @@ def parse_coverage_data_for_class(self, cls) -> Tuple[list, list, float]: cls (Element): XML element representing the class. Returns: - Tuple[list, list, float]: A tuple containing lists of covered and missed line numbers, - and the coverage percentage. + Tuple[list, list, float, float]: A tuple containing lists of covered and missed line numbers, + the line coverage percentage, and the branch coverage percentage. """ - lines_covered, lines_missed = [], [] + lines_covered = [] + lines_missed = [] + branches_covered = 0 + total_branches = 0 for line in cls.findall(".//line"): line_number = int(line.get("number")) @@ -161,14 +164,37 @@ def parse_coverage_data_for_class(self, cls) -> Tuple[list, list, float]: else: lines_missed.append(line_number) - total_lines = len(lines_covered) + len(lines_missed) - coverage_percentage = (len(lines_covered) / total_lines) if total_lines > 0 else 0 + branch_rate = line.get("branch") + if branch_rate: + condition_coverage = line.get('condition-coverage') + covered, total = map(int, condition_coverage.split('(')[1].split(')')[0].split('/')) + total_branches += total + branches_covered += covered - return lines_covered, lines_missed, coverage_percentage + line_coverage_percentage = len(lines_covered) / (len(lines_covered) + len(lines_missed)) if (len(lines_covered) + len(lines_missed)) > 0 else 0.0 + branch_coverage_percentage = branches_covered / total_branches if total_branches > 0 else 0.0 - def parse_coverage_report_lcov(self): + return lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage + def parse_coverage_report_lcov(self): + """ + Parses an LCOV coverage report file and calculates line and branch coverage. + This method reads an LCOV coverage report file specified by `self.file_path` and + extracts information about covered and missed lines, as well as branch coverage. + It calculates the percentage of line and branch coverage based on the data extracted. + Returns: + tuple: A tuple containing: + - lines_covered (list): A list of line numbers that are covered. + - lines_missed (list): A list of line numbers that are missed. + - line_coverage_percentage (float): The percentage of lines covered. + - branch_coverage_percentage (float): The percentage of branches covered. + Raises: + FileNotFoundError: If the specified file does not exist. + IOError: If there is an error reading the file. + """ lines_covered, lines_missed = [], [] + branches_covered, branches_missed = 0, 0 + total_branches = 0 filename = os.path.basename(self.src_file_path) try: with open(self.file_path, "r") as file: @@ -185,6 +211,12 @@ def parse_coverage_report_lcov(self): lines_covered.append(int(line_number)) else: lines_missed.append(int(line_number)) + elif line.startswith("BRDA:"): + parts = line.replace("BRDA:", "").split(",") + branch_hits = parts[3] + total_branches += 1 + if branch_hits != '-' and int(branch_hits) > 0: + branches_covered += 1 elif line.startswith("end_of_record"): break @@ -193,29 +225,31 @@ def parse_coverage_report_lcov(self): raise total_lines = len(lines_covered) + len(lines_missed) - coverage_percentage = ( + line_coverage_percentage = ( (len(lines_covered) / total_lines) if total_lines > 0 else 0 ) + branch_coverage_percentage = ( + (branches_covered / total_branches) if total_branches > 0 else 0 + ) - return lines_covered, lines_missed, coverage_percentage + return lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage def parse_coverage_report_jacoco(self) -> Tuple[list, list, float]: """ Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file, - and calculates the coverage percentage. + and calculates the line and branch coverage percentages. - Returns: Tuple[list, list, float]: A tuple containing empty lists of covered and missed line numbers, - and the coverage percentage. The reason being the format of the report for jacoco gives the totals we do not - sum them up. to stick with the current contract of the code and to do little change returning empty arrays. - I expect this should bring up a discussion on introduce a factory for different CoverageProcessors. Where the - total coverage percentage is returned to be evaluated only. + Returns: + Tuple[list, list, float, float]: A tuple containing lists of covered and missed line numbers, + the line coverage percentage, and the branch coverage percentage. """ lines_covered, lines_missed = [], [] + branches_covered, branches_missed = 0, 0 source_file_extension = self.get_file_extension(self.src_file_path) package_name, class_name = "","" if source_file_extension == 'java': - package_name, class_name = self.extract_package_and_class_java() + package_name, class_name= self.extract_package_and_class_java() elif source_file_extension == 'kt': package_name, class_name = self.extract_package_and_class_kotlin() else: @@ -227,25 +261,40 @@ def parse_coverage_report_jacoco(self) -> Tuple[list, list, float]: missed, covered = 0, 0 if file_extension == 'xml': - missed, covered = self.parse_missed_covered_lines_jacoco_xml( + missed, covered, branches_missed, branches_covered = self.parse_missed_covered_lines_jacoco_xml( class_name ) elif file_extension == 'csv': - missed, covered = self.parse_missed_covered_lines_jacoco_csv( + missed, covered, branches_missed, branches_covered = self.parse_missed_covered_lines_jacoco_csv( package_name, class_name ) else: raise ValueError(f"Unsupported JaCoCo code coverage report format: {file_extension}") total_lines = missed + covered - coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0 + total_branches = branches_covered + branches_missed + line_coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0 + branch_coverage_percentage = (float(branches_covered) / total_branches) if total_branches > 0 else 0 - return lines_covered, lines_missed, coverage_percentage + return lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage def parse_missed_covered_lines_jacoco_xml( self, class_name: str - ) -> tuple[int, int]: - """Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file.""" + ) -> tuple[int, int, int, int]: + """ + Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file, + and calculates the line and branch coverage percentages. + + Args: + class_name (str): The name of the class to process. + + Returns: + tuple: A tuple containing: + - missed (int): The number of missed lines. + - covered (int): The number of covered lines. + - missed_branches (int): The number of missed branches. + - covered_branches (int): The number of covered branches. + """ tree = ET.parse(self.file_path) root = tree.getroot() sourcefile = ( @@ -254,34 +303,50 @@ def parse_missed_covered_lines_jacoco_xml( ) if sourcefile is None: - return 0, 0 + return 0, 0, 0, 0 missed, covered = 0, 0 + missed_branches, covered_branches = 0, 0 for counter in sourcefile.findall('counter'): if counter.attrib.get('type') == 'LINE': missed += int(counter.attrib.get('missed', 0)) covered += int(counter.attrib.get('covered', 0)) - break + elif counter.attrib.get('type') == 'BRANCH': + missed_branches += int(counter.attrib.get('missed', 0)) + covered_branches += int(counter.attrib.get('covered', 0)) - return missed, covered + return missed, covered, missed_branches, covered_branches def parse_missed_covered_lines_jacoco_csv( self, package_name: str, class_name: str - ) -> tuple[int, int]: + ) -> tuple[int, int, int, int]: + """ + Parses the JaCoCo CSV report to extract the number of missed and covered lines and branches for a specific class. + Args: + package_name (str): The name of the package to filter the CSV data. + class_name (str): The name of the class to filter the CSV data. + Returns: + tuple[int, int, int, int]: A tuple containing the number of missed lines, covered lines, missed branches, and covered branches. + Raises: + KeyError: If the expected columns are not found in the CSV file. + """ with open(self.file_path, "r") as file: reader = csv.DictReader(file) missed, covered = 0, 0 + missed_branches, covered_branches = 0, 0 for row in reader: if row["PACKAGE"] == package_name and row["CLASS"] == class_name: try: missed = int(row["LINE_MISSED"]) covered = int(row["LINE_COVERED"]) + missed_branches = int(row["BRANCH_MISSED"]) + covered_branches = int(row["BRANCH_COVERED"]) break except KeyError as e: self.logger.error(f"Missing expected column in CSV: {str(e)}") raise - return missed, covered + return missed, covered, missed_branches, covered_branches def extract_package_and_class_java(self): package_pattern = re.compile(r"^\s*package\s+([\w\.]+)\s*;.*$") diff --git a/cover_agent/UnitTestValidator.py b/cover_agent/UnitTestValidator.py index 561a3ae7f..a777a12cd 100644 --- a/cover_agent/UnitTestValidator.py +++ b/cover_agent/UnitTestValidator.py @@ -293,10 +293,11 @@ def run_coverage(self): try: # Process the extracted coverage metrics - coverage, coverage_percentages = self.post_process_coverage_report( + coverage, branch_coverage, coverage_percentages = self.post_process_coverage_report( time_of_test_command ) self.current_coverage = coverage + self.current_branch_coverage = branch_coverage self.last_coverage_percentages = coverage_percentages.copy() self.logger.info( f"Initial coverage: {round(self.current_coverage * 100, 2)}%" @@ -502,7 +503,7 @@ def validate_test(self, generated_test: dict, num_attempts=1): # If test passed, check for coverage increase try: - new_percentage_covered, new_coverage_percentages = self.post_process_coverage_report( + new_percentage_covered, new_branch_coverage, new_coverage_percentages = self.post_process_coverage_report( time_of_test_command ) @@ -583,15 +584,17 @@ def validate_test(self, generated_test: dict, num_attempts=1): self.logger.info( f"Coverage for provided source file: {key} increased from {round(self.last_coverage_percentages[key] * 100, 2)} to {round(new_coverage_percentages[key] * 100, 2)}" ) + elif new_coverage_percentages[key] > self.last_coverage_percentages[key]: self.logger.info( f"Coverage for non-source file: {key} increased from {round(self.last_coverage_percentages[key] * 100, 2)} to {round(new_coverage_percentages[key] * 100, 2)}" ) self.current_coverage = new_percentage_covered + self.current_branch_coverage = new_branch_coverage self.last_coverage_percentages = new_coverage_percentages.copy() self.logger.info( - f"Test passed and coverage increased. Current coverage: {round(new_percentage_covered * 100, 2)}%" + f"Test passed and coverage increased. Current coverage: {round(new_percentage_covered * 100, 2)}% and Branch Coverage: {round(new_branch_coverage * 100, 2)}%" ) return { "status": "PASS", @@ -695,7 +698,7 @@ def post_process_coverage_report(self, time_of_test_command): total_lines_missed = 0 total_lines = 0 for key in file_coverage_dict: - lines_covered, lines_missed, percentage_covered = ( + lines_covered, lines_missed, percentage_covered, branch_covered = ( file_coverage_dict[key] ) total_lines_covered += len(lines_covered) @@ -720,20 +723,20 @@ def post_process_coverage_report(self, time_of_test_command): ) elif self.diff_coverage: self.generate_diff_coverage_report() - lines_covered, lines_missed, percentage_covered = ( + lines_covered, lines_missed, percentage_covered, branch_covered = ( self.coverage_processor.process_coverage_report( time_of_test_command=time_of_test_command ) ) self.code_coverage_report = f"Lines covered: {lines_covered}\nLines missed: {lines_missed}\nPercentage covered: {round(percentage_covered * 100, 2)}%" else: - lines_covered, lines_missed, percentage_covered = ( + lines_covered, lines_missed, percentage_covered, branch_covered = ( self.coverage_processor.process_coverage_report( time_of_test_command=time_of_test_command ) ) self.code_coverage_report = f"Lines covered: {lines_covered}\nLines missed: {lines_missed}\nPercentage covered: {round(percentage_covered * 100, 2)}%" - return percentage_covered, coverage_percentages + return percentage_covered, branch_covered, coverage_percentages def generate_diff_coverage_report(self): diff --git a/templated_tests/c_cli/build_and_test_with_coverage.sh b/templated_tests/c_cli/build_and_test_with_coverage.sh index efdec79fb..1db0331a0 100644 --- a/templated_tests/c_cli/build_and_test_with_coverage.sh +++ b/templated_tests/c_cli/build_and_test_with_coverage.sh @@ -21,9 +21,9 @@ echo "Running tests..." # Capture coverage data and generate reports echo "Generating coverage reports..." -lcov --capture --directory . --output-file coverage.info -lcov --remove coverage.info '*/Unity/*' '*/test_*' --output-file coverage_filtered.info -lcov --list coverage_filtered.info +lcov --capture --directory . --output-file coverage.info --rc lcov_branch_coverage=1 +lcov --remove coverage.info '*/Unity/*' '*/test_*' --output-file coverage_filtered.info --rc lcov_branch_coverage=1 +lcov --list coverage_filtered.info --rc lcov_branch_coverage=1 # convert lcov to cobertura lcov_cobertura coverage_filtered.info diff --git a/templated_tests/cpp_cli/build_and_test_with_coverage.sh b/templated_tests/cpp_cli/build_and_test_with_coverage.sh index d6c6c7aea..4375e77e7 100644 --- a/templated_tests/cpp_cli/build_and_test_with_coverage.sh +++ b/templated_tests/cpp_cli/build_and_test_with_coverage.sh @@ -14,9 +14,9 @@ echo "Running tests..." # Capture coverage data and generate reports echo "Generating coverage reports..." -lcov --capture --directory . --output-file coverage.info -lcov --remove coverage.info '*/usr/*' '*/test_*' --output-file coverage_filtered.info -lcov --list coverage_filtered.info +lcov --capture --directory . --output-file coverage.info --rc lcov_branch_coverage=1 +lcov --remove coverage.info '*/usr/*' '*/test_*' --output-file coverage_filtered.info --rc lcov_branch_coverage=1 +lcov --list coverage_filtered.info --rc lcov_branch_coverage=1 # convert lcov to cobertura lcov_cobertura coverage_filtered.info diff --git a/tests/test_CoverageProcessor.py b/tests/test_CoverageProcessor.py index 39330afa8..359265750 100644 --- a/tests/test_CoverageProcessor.py +++ b/tests/test_CoverageProcessor.py @@ -41,11 +41,12 @@ def test_parse_coverage_report_cobertura(self, mock_xml_tree, processor): """ Tests the parse_coverage_report method for correct line number and coverage calculation with Cobertura reports. """ - covered_lines, missed_lines, coverage_pct = processor.parse_coverage_report() + covered_lines, missed_lines, coverage_pct, branch_coverage = processor.parse_coverage_report() assert covered_lines == [1], "Should list line 1 as covered" assert missed_lines == [2], "Should list line 2 as missed" assert coverage_pct == 0.5, "Coverage should be 50 percent" + assert branch_coverage == 0.0, "Branch coverage should be 0 percent" def test_correct_parsing_for_matching_package_and_class(self, mocker): # Setup @@ -63,6 +64,8 @@ def test_correct_parsing_for_matching_package_and_class(self, mocker): "CLASS": "MyClass", "LINE_MISSED": "5", "LINE_COVERED": "10", + "BRANCH_MISSED": "2", + "BRANCH_COVERED": "6", } ], ) @@ -71,13 +74,15 @@ def test_correct_parsing_for_matching_package_and_class(self, mocker): ) # Action - missed, covered = processor.parse_missed_covered_lines_jacoco_csv( + missed, covered, branch_missed, branch_covered = processor.parse_missed_covered_lines_jacoco_csv( "com.example", "MyClass" ) # Assert assert missed == 5 assert covered == 10 + assert branch_missed == 2 + assert branch_covered == 6 def test_returns_empty_lists_and_float(self, mocker): # Mocking the necessary methods @@ -87,7 +92,7 @@ def test_returns_empty_lists_and_float(self, mocker): ) mocker.patch( "cover_agent.CoverageProcessor.CoverageProcessor.parse_missed_covered_lines_jacoco_xml", - return_value=(0, 0), + return_value=(0, 0, 0, 0), ) # Initialize the CoverageProcessor object @@ -98,7 +103,7 @@ def test_returns_empty_lists_and_float(self, mocker): ) # Invoke the parse_coverage_report_jacoco method - lines_covered, lines_missed, coverage_percentage = ( + lines_covered, lines_missed, coverage_percentage, branch_coverage = ( coverage_processor.parse_coverage_report_jacoco() ) @@ -106,6 +111,7 @@ def test_returns_empty_lists_and_float(self, mocker): assert lines_covered == [], "Expected lines_covered to be an empty list" assert lines_missed == [], "Expected lines_missed to be an empty list" assert coverage_percentage == 0, "Expected coverage percentage to be 0" + assert branch_coverage == 0, "Expected branch coverage to be 0" def test_parse_coverage_report_unsupported_type(self): processor = CoverageProcessor("fake_path", "app.py", "unsupported_type") @@ -192,7 +198,7 @@ def test_process_coverage_report(self, mocker): ) mock_parse = mocker.patch( "cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report", - return_value=([], [], 0.0), + return_value=([], [], 0.0, 0.0), ) processor = CoverageProcessor("fake_path", "app.py", "cobertura") @@ -200,7 +206,7 @@ def test_process_coverage_report(self, mocker): mock_verify.assert_called_once_with(1234567890) mock_parse.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" def test_parse_missed_covered_lines_jacoco_csv_key_error(self, mocker): mock_open = mocker.patch( @@ -229,10 +235,11 @@ def test_parse_coverage_report_lcov_no_coverage_data(self, mocker): """ mocker.patch("builtins.open", mocker.mock_open(read_data="")) processor = CoverageProcessor("empty_report.lcov", "app.py", "lcov") - covered_lines, missed_lines, coverage_pct = processor.parse_coverage_report_lcov() + covered_lines, missed_lines, coverage_pct, branch_pct = processor.parse_coverage_report_lcov() assert covered_lines == [], "Expected no covered lines" assert missed_lines == [], "Expected no missed lines" assert coverage_pct == 0, "Expected 0% coverage" + assert branch_pct == 0, "Expected 0% branch coverage" def test_parse_coverage_report_lcov_with_coverage_data(self, mocker): """ @@ -247,10 +254,11 @@ def test_parse_coverage_report_lcov_with_coverage_data(self, mocker): """ mocker.patch("builtins.open", mocker.mock_open(read_data=lcov_data)) processor = CoverageProcessor("report.lcov", "app.py", "lcov") - covered_lines, missed_lines, coverage_pct = processor.parse_coverage_report_lcov() + covered_lines, missed_lines, coverage_pct, branch_pct = processor.parse_coverage_report_lcov() assert covered_lines == [1, 3], "Expected lines 1 and 3 to be covered" assert missed_lines == [2], "Expected line 2 to be missed" assert coverage_pct == 2/3, "Expected 66.67% coverage" + assert branch_pct == 0, "Expected 0% branch coverage" def test_parse_coverage_report_lcov_with_multiple_files(self, mocker): """ @@ -272,12 +280,13 @@ def test_parse_coverage_report_lcov_with_multiple_files(self, mocker): """ mocker.patch("builtins.open", mocker.mock_open(read_data=lcov_data)) processor = CoverageProcessor("report.lcov", "app.py", "lcov") - covered_lines, missed_lines, coverage_pct = processor.parse_coverage_report_lcov() + covered_lines, missed_lines, coverage_pct, branch_pct = processor.parse_coverage_report_lcov() assert covered_lines == [1, 3], "Expected lines 1 and 3 to be covered for app.py" assert missed_lines == [2], "Expected line 2 to be missed for app.py" assert coverage_pct == 2/3, "Expected 66.67% coverage for app.py" + assert branch_pct == 0, "Expected 0% branch coverage for app.py" - def test_parse_coverage_report_unsupported_type(self, mocker): + def test_parse_coverage_report_unsupported_jacoco_type(self, mocker): mocker.patch( "cover_agent.CoverageProcessor.CoverageProcessor.extract_package_and_class_java", return_value=("com.example", "Example"), @@ -321,13 +330,15 @@ def test_parse_missed_covered_lines_jacoco_xml_no_source_file(self, mocker): ) # Action - missed, covered = processor.parse_missed_covered_lines_jacoco_xml( + missed, covered, branch_missed, branch_covered = processor.parse_missed_covered_lines_jacoco_xml( "MySecondClass" ) # Assert assert missed == 0 assert covered == 0 + assert branch_missed == 0 + assert branch_covered == 0 def test_parse_missed_covered_lines_jacoco_xml(self, mocker): #, mock_xml_tree @@ -359,13 +370,15 @@ def test_parse_missed_covered_lines_jacoco_xml(self, mocker): ) # Action - missed, covered = processor.parse_missed_covered_lines_jacoco_xml( + missed, covered, branch_missed, branch_covered = processor.parse_missed_covered_lines_jacoco_xml( "MyClass" ) # Assert assert missed == 9 assert covered == 94 + assert branch_missed == 2 + assert branch_covered == 6 def test_parse_missed_covered_lines_kotlin_jacoco_xml(self, mocker): #, mock_xml_tree @@ -397,13 +410,15 @@ def test_parse_missed_covered_lines_kotlin_jacoco_xml(self, mocker): ) # Action - missed, covered = processor.parse_missed_covered_lines_jacoco_xml( + missed, covered, branch_missed, branch_covered = processor.parse_missed_covered_lines_jacoco_xml( "MyClass" ) # Assert assert missed == 9 assert covered == 94 + assert branch_missed == 2 + assert branch_covered == 6 def test_get_file_extension_with_valid_file_extension(self): processor = CoverageProcessor( @@ -426,34 +441,35 @@ def test_get_file_extension_with_no_file_extension(self): assert file_extension is '' def test_parse_coverage_report_lcov_with_feature_flag(self, mocker): - mock_parse_lcov = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_lcov", return_value=([], [], 0.0)) + mock_parse_lcov = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_lcov", return_value=([], [], 0.0, 0.0)) processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=True) result = processor.parse_coverage_report() mock_parse_lcov.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" def test_parse_coverage_report_cobertura_with_feature_flag(self, mocker): - mock_parse_cobertura = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_cobertura", return_value=([], [], 0.0)) + mock_parse_cobertura = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_cobertura", return_value=([], [], 0.0, 0.0)) processor = CoverageProcessor("fake_path", "app.py", "cobertura", use_report_coverage_feature_flag=True) result = processor.parse_coverage_report() mock_parse_cobertura.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" def test_parse_coverage_report_jacoco(self, mocker): - mock_parse_jacoco = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_jacoco", return_value=([], [], 0.0)) + mock_parse_jacoco = mocker.patch("cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_jacoco", return_value=([], [], 0.0, 0.0)) processor = CoverageProcessor("fake_path", "app.py", "jacoco", use_report_coverage_feature_flag=True) result = processor.parse_coverage_report() mock_parse_jacoco.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" def test_parse_coverage_report_cobertura_filename_not_found(self, mock_xml_tree, processor): - covered_lines, missed_lines, coverage_pct = processor.parse_coverage_report_cobertura("non_existent_file.py") + covered_lines, missed_lines, coverage_pct, branch_coverage = processor.parse_coverage_report_cobertura("non_existent_file.py") assert covered_lines == [], "Expected no covered lines" assert missed_lines == [], "Expected no missed lines" assert coverage_pct == 0.0, "Expected 0% coverage" + assert branch_coverage == 0.0, "Expected 0% branch coverage" def test_parse_coverage_report_lcov_file_read_error(self, mocker): @@ -466,7 +482,7 @@ def test_parse_coverage_report_lcov_file_read_error(self, mocker): def test_parse_coverage_report_cobertura_all_files(self, mock_xml_tree, processor): coverage_data = processor.parse_coverage_report_cobertura() expected_data = { - "app.py": ([1], [2], 0.5) + "app.py": ([1], [2], 0.5, 0.0) } assert coverage_data == expected_data, "Expected coverage data for all files" @@ -479,12 +495,12 @@ def test_parse_coverage_report_unsupported_type_with_feature_flag(self): def test_parse_coverage_report_jacoco_without_feature_flag(self, mocker): mock_parse_jacoco = mocker.patch( "cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_jacoco", - return_value=([], [], 0.0) + return_value=([], [], 0.0, 0.0) ) processor = CoverageProcessor("fake_path", "app.py", "jacoco", use_report_coverage_feature_flag=False) result = processor.parse_coverage_report() mock_parse_jacoco.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" def test_parse_coverage_report_unsupported_type_without_feature_flag(self): processor = CoverageProcessor("fake_path", "app.py", "unsupported_type", use_report_coverage_feature_flag=False) @@ -496,10 +512,124 @@ def test_parse_coverage_report_unsupported_type_without_feature_flag(self): def test_parse_coverage_report_lcov_without_feature_flag(self, mocker): mock_parse_lcov = mocker.patch( "cover_agent.CoverageProcessor.CoverageProcessor.parse_coverage_report_lcov", - return_value=([], [], 0.0) + return_value=([], [], 0.0, 0.0) ) processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) result = processor.parse_coverage_report() mock_parse_lcov.assert_called_once() - assert result == ([], [], 0.0), "Expected result to be ([], [], 0.0)" + assert result == ([], [], 0.0, 0.0), "Expected result to be ([], [], 0.0, 0.0)" + + def test_parse_coverage_data_for_class_all_covered(self): + """ + Test parse_coverage_data_for_class when all lines are covered. + """ + xml_str = """ + + + + + """ + cls = ET.fromstring(xml_str) + processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) + lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage = processor.parse_coverage_data_for_class(cls) + assert lines_covered == [1, 2], "Expected lines 1 and 2 to be covered" + assert lines_missed == [], "Expected no missed lines" + assert line_coverage_percentage == 1.0, "Expected 100% line coverage" + assert branch_coverage_percentage == 1.0, "Expected 100% branch coverage" + + + def test_parse_coverage_data_for_class_all_missed(self): + """ + Test parse_coverage_data_for_class when all lines are missed. + """ + xml_str = """ + + + + + """ + cls = ET.fromstring(xml_str) + processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) + lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage = processor.parse_coverage_data_for_class(cls) + assert lines_covered == [], "Expected no covered lines" + assert lines_missed == [1, 2], "Expected lines 1 and 2 to be missed" + assert line_coverage_percentage == 0.0, "Expected 0% line coverage" + assert branch_coverage_percentage == 0.0, "Expected 0% branch coverage" + + + def test_parse_coverage_data_for_class_mixed_coverage(self): + """ + Test parse_coverage_data_for_class when some lines are covered and some are missed. + """ + xml_str = """ + + + + + """ + cls = ET.fromstring(xml_str) + processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) + lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage = processor.parse_coverage_data_for_class(cls) + assert lines_covered == [1], "Expected line 1 to be covered" + assert lines_missed == [2], "Expected line 2 to be missed" + assert line_coverage_percentage == 0.5, "Expected 50% line coverage" + assert branch_coverage_percentage == 0.0, "Expected 0% branch coverage" + + + def test_parse_coverage_data_for_class_with_branch_coverage(self): + """ + Test parse_coverage_data_for_class with branch coverage data. + """ + xml_str = """ + + + + + """ + cls = ET.fromstring(xml_str) + processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) + lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage = processor.parse_coverage_data_for_class(cls) + assert lines_covered == [1], "Expected line 1 to be covered" + assert lines_missed == [2], "Expected line 2 to be missed" + assert line_coverage_percentage == 0.5, "Expected 50% line coverage" + assert branch_coverage_percentage == 0.5, "Expected 50% branch coverage" + + + def test_parse_coverage_data_for_class_no_lines(self): + """ + Test parse_coverage_data_for_class when there are no lines. + """ + xml_str = """ + + + """ + cls = ET.fromstring(xml_str) + processor = CoverageProcessor("fake_path", "app.py", "lcov", use_report_coverage_feature_flag=False) + lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage = processor.parse_coverage_data_for_class(cls) + assert lines_covered == [], "Expected no covered lines" + assert lines_missed == [], "Expected no missed lines" + assert line_coverage_percentage == 0.0, "Expected 0% line coverage" + assert branch_coverage_percentage == 0.0, "Expected 0% branch coverage" + + + def test_parse_coverage_report_lcov_with_branch_coverage(self, mocker): + """ + Test parse_coverage_report_lcov correctly parses branch coverage data from an lcov report. + """ + lcov_data = """ + SF:app.py + DA:1,1 + DA:2,0 + DA:3,1 + BRDA:1,0,0,1 + BRDA:1,0,1,0 + end_of_record + """ + mocker.patch("builtins.open", mocker.mock_open(read_data=lcov_data)) + processor = CoverageProcessor("report.lcov", "app.py", "lcov") + covered_lines, missed_lines, coverage_pct, branch_pct = processor.parse_coverage_report_lcov() + assert covered_lines == [1, 3], "Expected lines 1 and 3 to be covered" + assert missed_lines == [2], "Expected line 2 to be missed" + assert coverage_pct == 2/3, "Expected 66.67% coverage" + assert branch_pct == 0.5, "Expected 50% branch coverage" diff --git a/tests/test_UnitTestValidator.py b/tests/test_UnitTestValidator.py index 17a4a6564..a8840bbf6 100644 --- a/tests/test_UnitTestValidator.py +++ b/tests/test_UnitTestValidator.py @@ -66,10 +66,11 @@ def test_run_coverage_with_report_coverage_flag(self): use_report_coverage_feature_flag=True ) with patch.object(Runner, 'run_command', return_value=("", "", 0, datetime.datetime.now())): - with patch.object(CoverageProcessor, 'process_coverage_report', return_value={'test.py': ([], [], 1.0)}): + with patch.object(CoverageProcessor, 'process_coverage_report', return_value={'test.py': ([], [], 1.0, 1.0)}): generator.run_coverage() # Dividing by zero so we're expecting a logged error and a return of 0 assert generator.current_coverage == 0 + assert generator.current_branch_coverage == 1.0 def test_extract_error_message_with_prompt_builder(self): @@ -113,6 +114,7 @@ def test_validate_test_pass_no_coverage_increase_with_prompt(self): # Setup initial state generator.current_coverage = 0.5 + generator.current_branch_coverage = 0.5 generator.test_headers_indentation = 4 generator.relevant_line_number_to_insert_tests_after = 100 generator.relevant_line_number_to_insert_imports_after = 10 @@ -129,7 +131,7 @@ def test_validate_test_pass_no_coverage_increase_with_prompt(self): with patch("builtins.open", mock_file), \ patch.object(Runner, 'run_command', return_value=("", "", 0, datetime.datetime.now())), \ - patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.4)): + patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.4, 0.4)): result = generator.validate_test(test_to_validate) @@ -180,9 +182,10 @@ def test_post_process_coverage_report_with_report_coverage_flag(self): llm_model="gpt-3", use_report_coverage_feature_flag=True ) - with patch.object(CoverageProcessor, 'process_coverage_report', return_value={'test.py': ([1], [1], 1.0)}): - percentage_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) + with patch.object(CoverageProcessor, 'process_coverage_report', return_value={'test.py': ([1], [1], 1.0, 1.0)}): + percentage_covered, branch_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) assert percentage_covered == 0.5 + assert branch_covered == 1.0 assert coverage_percentages == {'test.py': 1.0} def test_post_process_coverage_report_with_diff_coverage(self): @@ -196,9 +199,11 @@ def test_post_process_coverage_report_with_diff_coverage(self): diff_coverage=True ) with patch.object(generator, 'generate_diff_coverage_report'), \ - patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.8)): - percentage_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) + patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.8, 0.5)): + percentage_covered, branch_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) assert percentage_covered == 0.8 + assert branch_covered == 0.5 + def test_post_process_coverage_report_without_flags(self): with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as temp_source_file: @@ -209,9 +214,10 @@ def test_post_process_coverage_report_without_flags(self): test_command="pytest", llm_model="gpt-3" ) - with patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.7)): - percentage_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) + with patch.object(CoverageProcessor, 'process_coverage_report', return_value=([], [], 0.7, 0.5)): + percentage_covered, branche_covered, coverage_percentages = generator.post_process_coverage_report(datetime.datetime.now()) assert percentage_covered == 0.7 + assert branche_covered == 0.5 def test_generate_diff_coverage_report(self): with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as temp_source_file: diff --git a/tests_integration/test_all.sh b/tests_integration/test_all.sh index b0ab24987..623bb4864 100755 --- a/tests_integration/test_all.sh +++ b/tests_integration/test_all.sh @@ -128,7 +128,7 @@ sh tests_integration/test_with_docker.sh \ --docker-image "embeddeddevops/python_fastapi:latest" \ --source-file-path "app.py" \ --test-file-path "test_app.py" \ - --test-command "pytest --cov=. --cov-report=xml --cov-report=term" \ + --test-command "pytest --cov=. --cov-report=xml --cov-report=term --cov-branch" \ --model "gpt-3.5-turbo" \ $log_db_arg