Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Branch Coverage #196

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cover_agent/CoverAgent.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ def run(self):
):
# Log the current coverage
self.logger.info(
f"Current Coverage: {round(self.test_gen.current_coverage * 100, 2)}%"
f"Current Line Coverage: {round(self.test_gen.current_coverage * 100, 2)}%, Current Branch Coverage: {round(self.test_gen.branches_coverage * 100, 2)}%"
)
self.logger.info(f"Desired Coverage: {self.test_gen.desired_coverage}%")
self.logger.info(f"Desired Line Coverage: {self.test_gen.desired_coverage}%")

# Generate new tests
generated_tests_dict = self.test_gen.generate_tests(max_tokens=4096)
Expand All @@ -154,10 +154,10 @@ def run(self):
# Log the final coverage
if self.test_gen.current_coverage >= (self.test_gen.desired_coverage / 100):
self.logger.info(
f"Reached above target coverage of {self.test_gen.desired_coverage}% (Current Coverage: {round(self.test_gen.current_coverage * 100, 2)}%) in {iteration_count} iterations."
f"Reached above target coverage of {self.test_gen.desired_coverage}% (Current Line Coverage: {round(self.test_gen.current_coverage * 100, 2)}%, Current Branch Coverage: {round(self.test_gen.branches_coverage * 100, 2)}%) in {iteration_count} iterations."
)
elif iteration_count == self.args.max_iterations:
failure_message = f"Reached maximum iteration limit without achieving desired coverage. Current Coverage: {round(self.test_gen.current_coverage * 100, 2)}%"
failure_message = f"Reached maximum iteration limit without achieving desired coverage. Current Line Coverage: {round(self.test_gen.current_coverage * 100, 2)}%, Current Branch Coverage: {round(self.test_gen.branches_coverage * 100, 2)}%"
if self.args.strict_coverage:
# User requested strict coverage (similar to "--cov-fail-under in pytest-cov"). Fail with exist code 2.
self.logger.error(failure_message)
Expand Down
123 changes: 80 additions & 43 deletions cover_agent/CoverageProcessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,11 @@ def parse_coverage_report_cobertura(self, filename: str = None) -> Union[Tuple[l
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,
and the coverage percentage as values.
"""
tree = ET.parse(self.file_path)
root = tree.getroot()
Expand All @@ -125,28 +125,32 @@ 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, line_coverage_percentage, branch_coverage_percentage = self.parse_coverage_data_for_class(cls)
coverage_data[cls_filename] = (lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage)
self.logger.debug(f"Coverage data for all files: {coverage_data}")
return coverage_data

def parse_coverage_data_for_class(self, cls) -> Tuple[list, list, float]:
def parse_coverage_data_for_class(self, cls) -> Tuple[list, list, float, float]:
"""
Parses coverage data for a single class.
Parses coverage data for a specific class element.

Args:
cls (Element): XML element representing the class.
cls (Element): The class element from the Cobertura XML.

Returns:
Tuple[list, list, float]: A tuple containing lists of covered and missed line numbers,
and the coverage percentage.
Tuple[list, list, float, float]: 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"))
Expand All @@ -156,16 +160,33 @@ 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

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

return lines_covered, lines_missed, coverage_percentage
return lines_covered, lines_missed, line_coverage_percentage, branch_coverage_percentage

def parse_coverage_report_lcov(self):
"""
Parses an LCOV code coverage report to extract covered and missed line numbers,
branch coverage for a specific file, and calculates the coverage percentage.

Returns:
Tuple[list, list, float, float]: 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
total_branches = 0
filename = os.path.basename(self.src_file_path)
try:

try:
with open(self.file_path, "r") as file:
for line in file:
line = line.strip()
Expand All @@ -180,6 +201,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

Expand All @@ -188,82 +215,92 @@ 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]:

def parse_coverage_report_jacoco(self) -> Tuple[list, list, float, float]:
"""
Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file,
and calculates the coverage percentage.

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.
Parses a JaCoCo XML or CSV code coverage report to extract covered and missed line numbers,
branch coverage for a specific file, and calculates the coverage percentage.

Returns:
Tuple[list, list, float, float]: A tuple containing empty lists of covered and missed line numbers,
the line coverage percentage, and the branch 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 introducing a factory for
different CoverageProcessors. Where the total coverage percentage is returned to be evaluated only.
"""
lines_covered, lines_missed = [], []
branches_covered, branches_missed = 0, 0

package_name, class_name = self.extract_package_and_class_java()
file_extension = self.get_file_extension(self.file_path)

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]:
def parse_missed_covered_lines_jacoco_xml(self, class_name: str) -> tuple[int, int, int, int]:
"""Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file."""
tree = ET.parse(self.file_path)
root = tree.getroot()
sourcefile = root.find(f".//sourcefile[@name='{class_name}.java']")

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]:
def parse_missed_covered_lines_jacoco_csv(self, package_name: str, class_name: str) -> tuple[int, int, int, int]:
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("Missing expected column in CSV: {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*;.*$")
Expand Down
13 changes: 8 additions & 5 deletions cover_agent/UnitTestGenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def __init__(
self.use_report_coverage_feature_flag = use_report_coverage_feature_flag
self.last_coverage_percentages = {}
self.llm_model = llm_model
self.current_coverage = 0

# Objects to instantiate
self.ai_caller = AICaller(model=llm_model, api_base=api_base)
Expand Down Expand Up @@ -174,7 +175,7 @@ def run_coverage(self):
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)
Expand All @@ -198,15 +199,16 @@ def run_coverage(self):
f"coverage: Percentage {round(percentage_covered * 100, 2)}%"
)
else:
lines_covered, lines_missed, percentage_covered = (
lines_covered, lines_missed, percentage_covered, branches_covered = (
coverage_processor.process_coverage_report(
time_of_test_command=time_of_test_command
)
)

# Process the extracted coverage metrics
self.current_coverage = percentage_covered
self.code_coverage_report = f"Lines covered: {lines_covered}\nLines missed: {lines_missed}\nPercentage covered: {round(percentage_covered * 100, 2)}%"
self.branches_coverage = branches_covered
self.code_coverage_report = f"Lines covered: {lines_covered}\nLines missed: {lines_missed}\nPercentage covered: {round(percentage_covered * 100, 2)}%\nBranches covered: {branches_covered}"
except AssertionError as error:
# Handle the case where the coverage report does not exist or was not updated after the test command
self.logger.error(f"Error in coverage processing: {error}")
Expand Down Expand Up @@ -627,7 +629,7 @@ def validate_test(self, generated_test: dict, num_attempts=1):

new_percentage_covered = total_lines_covered / total_lines
else:
_, _, new_percentage_covered = (
_, _, new_percentage_covered, new_branches_covered = (
new_coverage_processor.process_coverage_report(
time_of_test_command=time_of_test_command
)
Expand Down Expand Up @@ -708,6 +710,7 @@ def validate_test(self, generated_test: dict, num_attempts=1):
) # this is important, otherwise the next test will be inserted at the wrong line

self.current_coverage = new_percentage_covered
self.branches_coverage = new_branches_covered

for key in coverage_percentages:
if key not in self.last_coverage_percentages:
Expand All @@ -723,7 +726,7 @@ def validate_test(self, generated_test: dict, num_attempts=1):
self.last_coverage_percentages[key] = coverage_percentages[key]

self.logger.info(
f"Test passed and coverage increased. Current coverage: {round(new_percentage_covered * 100, 2)}%"
f"Test passed and coverage increased. Current line coverage: {round(new_percentage_covered * 100, 2)}%, Current Branch coverage: {round(new_branches_covered * 100 , 2)}%"
)
return {
"status": "PASS",
Expand Down
9 changes: 3 additions & 6 deletions templated_tests/c_cli/build_and_test_with_coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,8 @@ 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

# convert lcov to cobertura
lcov_cobertura 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

echo "Test and coverage reporting complete."
9 changes: 3 additions & 6 deletions templated_tests/cpp_cli/build_and_test_with_coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@ 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

# convert lcov to cobertura
lcov_cobertura 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

echo "Test and coverage reporting complete."
Loading
Loading