diff --git a/README.md b/README.md index 0378d435..01bb0441 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,15 @@ jobs: # Same with orange. Below is red. MINIMUM_ORANGE: 70 + # Maximum number of files to display in the comment. If there are more + # files than this number, they will only appear in the workflow summary. + # The selected files are the ones with the most new uncovered lines. The + # closer this number gets to 35, the higher the risk that it reaches + # GitHub's maximum comment size limit of 65536 characters. If you want + # more files, you may need to use a custom comment template (see below). + # (Feel free to open an issue.) + MAX_FILES_IN_COMMENT: 25 + # If true, will run `coverage combine` before reading the `.coverage` file. MERGE_COVERAGE_FILES: false @@ -583,6 +592,14 @@ to use the svg badge directly, and not the `shields.io` URL. ## Upgrading from v2 to v3 -- When upgrading, we change the location and format where the coverage - data is kept. Pull request that have not been re-based may be displaying - slightly wrong information. +When upgrading, we change the location and format where the coverage +data is kept. Pull request that have not been re-based may be displaying +slightly wrong information. + +## New comment format starting with 3.19 + +Starting with 3.19, the format for the Pull Request changed to a table +with badges. We've been iterating a lot on the new format. +It's perfectly ok if you preferred the old format. In that case, see +#335 for instructions on how to emulate the old format using +`COMMENT_TEMPLATE`. diff --git a/action.yml b/action.yml index 42249d1b..362dfebf 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,17 @@ inputs: the badge will be orange. Otherwise it will be red. default: 70 required: false + MAX_FILES_IN_COMMENT: + description: > + Maximum number of files to display in the comment. If there are more + files than this number, they will only appear in the workflow summary. + The selected files are the ones with the most new uncovered lines. The + closer this number gets to 35, the higher the risk that it reaches + GitHub's maximum comment size limit of 65536 characters. If you want + more files, you may need to use a custom comment template. + (Feel free to open an issue.) + default: 25 + required: false MERGE_COVERAGE_FILES: description: > If true, will run `coverage combine` before reading the `.coverage` file. diff --git a/coverage_comment/badge.py b/coverage_comment/badge.py index c7090740..9871932d 100644 --- a/coverage_comment/badge.py +++ b/coverage_comment/badge.py @@ -24,6 +24,19 @@ def get_badge_color( return "red" +def get_evolution_badge_color( + delta: decimal.Decimal | int, + up_is_good: bool = True, + neutral_color: str = "lightgrey", +) -> str: + if delta == 0: + return neutral_color + elif (delta > 0) is up_is_good: + return "brightgreen" + else: + return "red" + + def compute_badge_endpoint_data( line_rate: decimal.Decimal, color: str, @@ -53,6 +66,15 @@ def compute_badge_image( ).text +def get_static_badge_url(label: str, message: str, color: str) -> str: + if not color or not message: + raise ValueError("color and message are required") + code = "-".join( + e.replace("_", "__").replace("-", "--") for e in (label, message, color) if e + ) + return "https://img.shields.io/badge/" + urllib.parse.quote(f"{code}.svg") + + def get_endpoint_url(endpoint_url: str) -> str: return f"https://img.shields.io/endpoint?url={endpoint_url}" diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py index f1976cb7..3e023fdf 100644 --- a/coverage_comment/coverage.py +++ b/coverage_comment/coverage.py @@ -10,6 +10,9 @@ from coverage_comment import log, subprocess +# The dataclasses in this module are accessible in the template, which is overridable by the user. +# As a coutesy, we should do our best to keep the existing fields for backward compatibility, +# and if we really can't and can't add properties, at least bump the major version. @dataclasses.dataclass class CoverageMetadata: version: str @@ -57,13 +60,17 @@ class Coverage: class FileDiffCoverage: path: pathlib.Path percent_covered: decimal.Decimal - missing_lines: list[int] + covered_statements: list[int] + missing_statements: list[int] + added_statements: list[int] + # Added lines tracks all the lines that were added in the diff, not just + # the statements (so it includes comments, blank lines, etc.) added_lines: list[int] # for backward compatibility @property def violation_lines(self) -> list[int]: - return self.missing_lines + return self.missing_statements @dataclasses.dataclass @@ -188,10 +195,8 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage: covered_lines=file_data["summary"]["covered_lines"], num_statements=file_data["summary"]["num_statements"], percent_covered=compute_coverage( - file_data["summary"]["covered_lines"] - + file_data["summary"].get("covered_branches", 0), - file_data["summary"]["num_statements"] - + file_data["summary"].get("num_branches", 0), + file_data["summary"]["covered_lines"], + file_data["summary"]["num_statements"], ), missing_lines=file_data["summary"]["missing_lines"], excluded_lines=file_data["summary"]["excluded_lines"], @@ -209,10 +214,8 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage: covered_lines=data["totals"]["covered_lines"], num_statements=data["totals"]["num_statements"], percent_covered=compute_coverage( - data["totals"]["covered_lines"] - + data["totals"].get("covered_branches", 0), - data["totals"]["num_statements"] - + data["totals"].get("num_branches", 0), + data["totals"]["covered_lines"], + data["totals"]["num_statements"], ), missing_lines=data["totals"]["missing_lines"], excluded_lines=data["totals"]["excluded_lines"], @@ -245,9 +248,9 @@ def get_diff_coverage_info( missing = set(file.missing_lines) & set(added_lines_for_file) count_missing = len(missing) - # Even partially covered lines are considered as covered, no line - # appears in both counts - count_total = count_executed + count_missing + + added = executed | missing + count_total = len(added) total_num_lines += count_total total_num_violations += count_missing @@ -259,7 +262,9 @@ def get_diff_coverage_info( files[path] = FileDiffCoverage( path=path, percent_covered=percent_covered, - missing_lines=sorted(missing), + covered_statements=sorted(executed), + missing_statements=sorted(missing), + added_statements=sorted(added), added_lines=added_lines_for_file, ) final_percentage = compute_coverage( diff --git a/coverage_comment/diff_grouper.py b/coverage_comment/diff_grouper.py new file mode 100644 index 00000000..bc9a54ca --- /dev/null +++ b/coverage_comment/diff_grouper.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from coverage_comment import coverage as coverage_module +from coverage_comment import groups + +MAX_ANNOTATION_GAP = 3 + + +def get_diff_missing_groups( + coverage: coverage_module.Coverage, + diff_coverage: coverage_module.DiffCoverage, +) -> Iterable[groups.Group]: + for path, diff_file in diff_coverage.files.items(): + coverage_file = coverage.files[path] + + # Lines that are covered or excluded should not be considered for + # filling a gap between violation groups. + # (so, lines that can appear in a gap are lines that are missing, or + # lines that do not contain code: blank lines or lines containing comments) + separators = { + *coverage_file.executed_lines, + *coverage_file.excluded_lines, + } + # Lines that are added should be considered for filling a gap, unless + # they are separators. + joiners = set(diff_file.added_lines) - separators + + for start, end in groups.compute_contiguous_groups( + values=diff_file.missing_statements, + separators=separators, + joiners=joiners, + max_gap=MAX_ANNOTATION_GAP, + ): + yield groups.Group( + file=path, + line_start=start, + line_end=end, + ) diff --git a/coverage_comment/files.py b/coverage_comment/files.py index 69ec48ec..fd09b58f 100644 --- a/coverage_comment/files.py +++ b/coverage_comment/files.py @@ -108,10 +108,18 @@ def compute_datafile( ) -def parse_datafile(contents) -> decimal.Decimal: - return decimal.Decimal(str(json.loads(contents)["coverage"])) / decimal.Decimal( +def parse_datafile(contents) -> tuple[coverage.Coverage | None, decimal.Decimal]: + file_contents = json.loads(contents) + coverage_rate = decimal.Decimal(str(file_contents["coverage"])) / decimal.Decimal( "100" ) + try: + return coverage.extract_info( + data=file_contents["raw_data"], + coverage_path=pathlib.Path(file_contents["coverage_path"]), + ), coverage_rate + except KeyError: + return None, coverage_rate class ImageURLs(TypedDict): diff --git a/coverage_comment/annotations.py b/coverage_comment/groups.py similarity index 52% rename from coverage_comment/annotations.py rename to coverage_comment/groups.py index d8f61094..7f44f164 100644 --- a/coverage_comment/annotations.py +++ b/coverage_comment/groups.py @@ -4,22 +4,17 @@ import functools import itertools import pathlib -from collections.abc import Iterable - -from coverage_comment import coverage as coverage_module - -MAX_ANNOTATION_GAP = 3 @dataclasses.dataclass(frozen=True) -class Annotation: +class Group: file: pathlib.Path line_start: int line_end: int def compute_contiguous_groups( - values: list[int], separators: set[int], joiners: set[int] + values: list[int], separators: set[int], joiners: set[int], max_gap: int ) -> list[tuple[int, int]]: """ Given a list of (sorted) values, a list of separators and a list of @@ -28,8 +23,8 @@ def compute_contiguous_groups( Groups are created by joining contiguous values together, and in some cases by merging groups, enclosing a gap of values between them. Gaps that may be - enclosed are small gaps (<= MAX_ANNOTATION_GAP values after removing all - joiners) where no line is a "separator" + enclosed are small gaps (<= max_gap values after removing all joiners) + where no line is a "separator" """ contiguous_groups: list[tuple[int, int]] = [] for _, contiguous_group in itertools.groupby( @@ -55,7 +50,7 @@ def reducer( gap = set(range(last_end + 1, next_start)) - joiners - gap_is_small = len(gap) <= MAX_ANNOTATION_GAP + gap_is_small = len(gap) <= max_gap gap_contains_separators = gap & separators if gap_is_small and not gap_contains_separators: @@ -66,34 +61,3 @@ def reducer( return acc return functools.reduce(reducer, contiguous_groups, []) - - -def group_annotations( - coverage: coverage_module.Coverage, - diff_coverage: coverage_module.DiffCoverage, -) -> Iterable[Annotation]: - for path, diff_file in diff_coverage.files.items(): - coverage_file = coverage.files[path] - - # Lines that are covered or excluded should not be considered for - # filling a gap between violation groups. - # (so, lines that can appear in a gap are lines that are missing, or - # lines that do not contain code: blank lines or lines containing comments) - separators = { - *coverage_file.executed_lines, - *coverage_file.excluded_lines, - } - # Lines that are added should be considered for filling a gap, unless - # they are separators. - joiners = set(diff_file.added_lines) - separators - - for start, end in compute_contiguous_groups( - values=diff_file.missing_lines, - separators=separators, - joiners=joiners, - ): - yield Annotation( - file=path, - line_start=start, - line_end=end, - ) diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 1857c44c..701a1c14 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -8,12 +8,10 @@ import httpx from coverage_comment import activity as activity_module -from coverage_comment import ( - annotations as annotations_module, -) from coverage_comment import ( comment_file, communication, + diff_grouper, files, github, github_client, @@ -151,16 +149,33 @@ def process_pr( branch=config.FINAL_COVERAGE_DATA_BRANCH, ) - previous_coverage = None + previous_coverage, previous_coverage_rate = None, None if previous_coverage_data_file: - previous_coverage = files.parse_datafile(contents=previous_coverage_data_file) + previous_coverage, previous_coverage_rate = files.parse_datafile( + contents=previous_coverage_data_file + ) marker = template.get_marker(marker_id=config.SUBPROJECT_ID) + + files_info, count_files = template.select_files( + coverage=coverage, + diff_coverage=diff_coverage, + previous_coverage=previous_coverage, + max_files=config.MAX_FILES_IN_COMMENT, + ) try: comment = template.get_comment_markdown( coverage=coverage, diff_coverage=diff_coverage, - previous_coverage_rate=previous_coverage, + previous_coverage=previous_coverage, + previous_coverage_rate=previous_coverage_rate, + files=files_info, + count_files=count_files, + max_files=config.MAX_FILES_IN_COMMENT, + minimum_green=config.MINIMUM_GREEN, + minimum_orange=config.MINIMUM_ORANGE, + repo_name=config.GITHUB_REPOSITORY, + pr_number=config.GITHUB_PR_NUMBER, base_template=template.read_template_file("comment.md.j2"), custom_template=config.COMMENT_TEMPLATE, pr_targets_default_branch=pr_targets_default_branch, @@ -203,7 +218,7 @@ def process_pr( pr_number = None if pr_number is not None and config.ANNOTATE_MISSING_LINES: - annotations = annotations_module.group_annotations( + annotations = diff_grouper.get_diff_missing_groups( coverage=coverage, diff_coverage=diff_coverage ) github.create_missing_coverage_annotations( diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py index acc09c5e..ed0c27c1 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -60,6 +60,7 @@ class Config: MERGE_COVERAGE_FILES: bool = False ANNOTATE_MISSING_LINES: bool = False ANNOTATION_TYPE: str = "warning" + MAX_FILES_IN_COMMENT: int = 25 VERBOSE: bool = False # Only for debugging, not exposed in the action: FORCE_WORKFLOW_RUN: bool = False diff --git a/coverage_comment/template.py b/coverage_comment/template.py index 8c1572f1..7a397878 100644 --- a/coverage_comment/template.py +++ b/coverage_comment/template.py @@ -1,12 +1,18 @@ from __future__ import annotations +import dataclasses import decimal +import functools +import hashlib +import itertools +import pathlib from collections.abc import Callable from importlib import resources import jinja2 from jinja2.sandbox import SandboxedEnvironment +from coverage_comment import badge, diff_grouper from coverage_comment import coverage as coverage_module MARKER = ( @@ -19,7 +25,9 @@ def uptodate(): class CommentLoader(jinja2.BaseLoader): - def __init__(self, base_template: str, custom_template: str | None): + def __init__( + self, base_template: str, custom_template: str | None, debug: bool = False + ): self.base_template = base_template self.custom_template = custom_template @@ -27,7 +35,11 @@ def get_source( self, environment: jinja2.Environment, template: str ) -> tuple[str, str | None, Callable[..., bool]]: if template == "base": - return self.base_template, None, uptodate + return ( + self.base_template, + "coverage_comment/template_files/comment.md.j2", + uptodate, + ) if self.custom_template and template == "custom": return self.custom_template, None, uptodate @@ -47,10 +59,77 @@ def get_marker(marker_id: str | None): return MARKER.format(id_part=f" (id: {marker_id})" if marker_id else "") +def pluralize(number, singular="", plural="s"): + if number == 1: + return singular + else: + return plural + + +def sign(val: int | decimal.Decimal) -> str: + return "+" if val > 0 else "" if val < 0 else "±" + + +def delta(val: int) -> str: + return f"({sign(val)}{val})" + + +def remove_exponent(val: decimal.Decimal) -> decimal.Decimal: + # From https://docs.python.org/3/library/decimal.html#decimal-faq + return ( + val.quantize(decimal.Decimal(1)) + if val == val.to_integral() + else val.normalize() + ) + + +def percentage_value(val: decimal.Decimal, precision: int = 2) -> decimal.Decimal: + return remove_exponent( + (decimal.Decimal("100") * val).quantize( + decimal.Decimal("1." + ("0" * precision)), + rounding=decimal.ROUND_DOWN, + ) + ) + + +def pct(val: decimal.Decimal, precision: int = 2) -> str: + rounded = percentage_value(val=val, precision=precision) + return f"{rounded:f}%" + + +def x100(val: decimal.Decimal): + return val * 100 + + +@dataclasses.dataclass +class FileInfo: + path: pathlib.Path + coverage: coverage_module.FileCoverage + diff: coverage_module.FileDiffCoverage | None + previous: coverage_module.FileCoverage | None + + @property + def new_missing_lines(self) -> list[int]: + missing_lines = set(self.coverage.missing_lines) + if self.previous: + missing_lines -= set(self.previous.missing_lines) + + return sorted(missing_lines) + + def get_comment_markdown( + *, coverage: coverage_module.Coverage, diff_coverage: coverage_module.DiffCoverage, previous_coverage_rate: decimal.Decimal | None, + previous_coverage: coverage_module.Coverage | None, + files: list[FileInfo], + max_files: int | None, + count_files: int, + minimum_green: decimal.Decimal, + minimum_orange: decimal.Decimal, + repo_name: str, + pr_number: int, base_template: str, marker: str, subproject_id: str | None = None, @@ -60,12 +139,39 @@ def get_comment_markdown( loader = CommentLoader(base_template=base_template, custom_template=custom_template) env = SandboxedEnvironment(loader=loader) env.filters["pct"] = pct + env.filters["delta"] = delta + env.filters["x100"] = x100 + env.filters["get_evolution_color"] = badge.get_evolution_badge_color + env.filters["generate_badge"] = badge.get_static_badge_url + env.filters["pluralize"] = pluralize + env.filters["file_url"] = functools.partial( + get_file_url, repo_name=repo_name, pr_number=pr_number + ) + env.filters["get_badge_color"] = functools.partial( + badge.get_badge_color, + minimum_green=minimum_green, + minimum_orange=minimum_orange, + ) + missing_diff_lines = { + key: list(value) + for key, value in itertools.groupby( + diff_grouper.get_diff_missing_groups( + coverage=coverage, diff_coverage=diff_coverage + ), + lambda x: x.file, + ) + } try: comment = env.get_template("custom" if custom_template else "base").render( previous_coverage_rate=previous_coverage_rate, coverage=coverage, diff_coverage=diff_coverage, + previous_coverage=previous_coverage, + count_files=count_files, + max_files=max_files, + files=files, + missing_diff_lines=missing_diff_lines, subproject_id=subproject_id, marker=marker, pr_targets_default_branch=pr_targets_default_branch, @@ -79,6 +185,47 @@ def get_comment_markdown( return comment +def select_files( + *, + coverage: coverage_module.Coverage, + diff_coverage: coverage_module.DiffCoverage, + previous_coverage: coverage_module.Coverage | None = None, + max_files: int | None, +) -> tuple[list[FileInfo], int]: + """ + Selects the MAX_FILES files with the most new missing lines sorted by path + + """ + previous_coverage_files = previous_coverage.files if previous_coverage else {} + + files = [] + for path, coverage_file in coverage.files.items(): + diff_coverage_file = diff_coverage.files.get(path) + previous_coverage_file = previous_coverage_files.get(path) + + file_info = FileInfo( + path=path, + coverage=coverage_file, + diff=diff_coverage_file, + previous=previous_coverage_file, + ) + has_diff = bool(diff_coverage_file) + has_evolution_from_previous = ( + previous_coverage_file.info != coverage_file.info + if previous_coverage_file + else False + ) + + if has_diff or has_evolution_from_previous: + files.append(file_info) + + count_files = len(files) + files = sorted(files, key=lambda x: len(x.new_missing_lines), reverse=True) + if max_files is not None: + files = files[:max_files] + return sorted(files, key=lambda x: x.path), count_files + + def get_readme_markdown( is_public: bool, readme_url: str, @@ -131,9 +278,18 @@ def read_template_file(template: str) -> str: ).read_text() -def pct(val: decimal.Decimal | float) -> str: - if isinstance(val, decimal.Decimal): - val *= decimal.Decimal("100") - return f"{val.quantize(decimal.Decimal('0.01'), rounding=decimal.ROUND_DOWN).normalize():f}%" - else: - return f"{val:.0%}" +def get_file_url( + filename: pathlib.Path, + lines: tuple[int, int] | None = None, + *, + repo_name: str, + pr_number: int, +) -> str: + # To link to a file in a PR, GitHub uses the link to the file overview combined with a SHA256 hash of the file path + s = f"https://github.com/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}" + + if lines is not None: + # R stands for Right side of the diff. But since we generate these links for new code we only need the right side. + s += f"R{lines[0]}-R{lines[1]}" + + return s diff --git a/coverage_comment/template_files/comment.md.j2 b/coverage_comment/template_files/comment.md.j2 index a4d6339a..650ade22 100644 --- a/coverage_comment/template_files/comment.md.j2 +++ b/coverage_comment/template_files/comment.md.j2 @@ -1,78 +1,264 @@ -{% block title %}## Coverage report{% if subproject_id %} ({{ subproject_id }}){% endif %}{% endblock title %} -{% block coverage_evolution -%} -{% if previous_coverage_rate -%} -{% block coverage_evolution_wording -%} -The coverage rate went from `{{ previous_coverage_rate | pct }}` to `{{ coverage.info.percent_covered | pct }}`{{" "}} -{%- endblock coverage_evolution_wording %} -{%- block emoji_coverage -%} -{%- if previous_coverage_rate | float < coverage.info.percent_covered | float -%} -{%- block emoji_coverage_up -%}:arrow_up:{%- endblock emoji_coverage_up -%} -{%- elif previous_coverage_rate | float > coverage.info.percent_covered | float -%} -{%- block emoji_coverage_down -%}:arrow_down:{%- endblock emoji_coverage_down -%} +{%- block title -%}## Coverage report{%- if subproject_id %} ({{ subproject_id }}){%- endif -%}{%- endblock title%} + +{# Coverage evolution badge #} +{% block coverage_badges -%} +{%- block coverage_evolution_badge -%} +{%- if previous_coverage_rate %} +{%- set text = "Coverage for the whole project went from " ~ (previous_coverage_rate | pct) ~ " to " ~ (coverage.info.percent_covered | pct) -%} +{%- set color = (coverage.info.percent_covered - previous_coverage_rate) | get_evolution_color(neutral_color='blue') -%} + + +{%- else -%} +{%- set text = "Coverage for the whole project is " ~ (coverage.info.percent_covered | pct) ~ ". Previous coverage rate is not available, cannot report on evolution." -%} +{%- set color = coverage.info.percent_covered | x100 | get_badge_color -%} + + +{%- endif -%} +{%- endblock coverage_evolution_badge -%} + +{#- Coverage diff badge -#} +{#- space #} {# space -#} +{%- block diff_coverage_badge -%} +{%- set text = (diff_coverage.total_percent_covered | pct) ~ " of the statement lines added by this PR are covered" -%} + + +{%- endblock diff_coverage_badge -%} +{%- endblock coverage_badges -%} + + +{%- macro statements_badge(path, statements_count, previous_statements_count) -%} +{% if previous_statements_count is not none -%} +{% set statements_diff = statements_count - previous_statements_count %} +{% if statements_diff > 0 -%} +{% set text = "This PR adds " ~ statements_diff ~ " to the number of statements in " ~ path ~ ", taking it from " ~ previous_statements_count ~ " to " ~ statements_count ~"." -%} +{% set color = "007ec6" -%} +{% elif statements_diff < 0 -%} +{% set text = "This PR removes " ~ (-statements_diff) ~ " from the number of statements in " ~ path ~ ", taking it from " ~ previous_statements_count ~ " to " ~ statements_count ~"." -%} +{% set color = "49c2ee" -%} +{% else -%} +{% set text = "This PR doesn't change the number of statements in " ~ path ~ ", which is " ~ statements_count ~ "." -%} +{% set color = "5d89ba" -%} +{% endif -%} +{% set message = statements_diff %} +{% else -%} +{% set text = "This PR adds " ~ statements_count ~ " statement" ~ (statements_count | pluralize) ~ " to " ~ path ~ ". The file did not seem to exist on the base branch." -%} +{% set color = "007ec6" -%} +{% set message = statements_count %} +{% endif -%} + + +{%- endmacro -%} + +{%- macro missing_lines_badge(path, missing_lines_count, previous_missing_lines_count) -%} +{%- if previous_missing_lines_count is not none -%} +{%- set missing_diff = missing_lines_count - previous_missing_lines_count %} +{%- if missing_diff > 0 -%} +{%- set text = "This PR adds " ~ missing_diff ~ " to the number of statements missing coverage in " ~ path ~ ", taking it from " ~ previous_missing_lines_count ~ " to " ~ missing_lines_count ~ "." -%} +{%- elif missing_diff < 0 -%} +{%- set text = "This PR removes " ~ (-missing_diff) ~ " from the number of statements missing coverage in " ~ path ~ ", taking it from " ~ previous_missing_lines_count ~ " to " ~ missing_lines_count ~ "." -%} {%- else -%} -{%- block emoji_coverage_constant -%}:arrow_right:{%- endblock emoji_coverage_constant -%} -{%- endif %} -{%- endblock emoji_coverage -%} +{%- set text = "This PR doesn't change the number of statements missing coverage in " ~ path ~ ", which is " ~ missing_lines_count ~ "." -%} +{%- endif -%} +{%- set message = missing_diff -%} {%- else -%} -{% block no_comparison_info -%} -{%- if pr_targets_default_branch -%} -{% block no_comparison_info_data_not_found_message -%} -> [!NOTE] -> Coverage data for the default branch was not found. -> This usually happens when the action has not run on the default -> branch yet, for example right after deploying it into the workflows. -{%- endblock no_comparison_info_data_not_found_message -%} -{% else %} -{% block no_comparison_info_non_default_target -%} -> [!NOTE] -> Coverage evolution disabled because this PR targets a different branch -> than the default branch, for which coverage data is not available. -{%- endblock no_comparison_info_non_default_target %} -{%- endif %} -{%- endblock no_comparison_info %} - -{% block coverage_value_wording -%} -The coverage rate is `{{ coverage.info.percent_covered | pct }}`. -{%- endblock coverage_value_wording %} -{%- endif %} -{%- endblock coverage_evolution %} -{% block branch_coverage -%} -{% if coverage.meta.branch_coverage and coverage.info.num_branches -%} -{% block branch_coverage_wording -%} -The branch rate is `{{ (coverage.info.covered_branches / coverage.info.num_branches) | pct }}`. -{% endblock branch_coverage_wording -%} -{%- endif %} -{% endblock branch_coverage -%} - -{%- if diff_coverage.total_num_lines > 0 -%} -{% block diff_coverage_wording -%} -`{{ diff_coverage.total_percent_covered | pct }}` of new lines are covered. -{%- endblock diff_coverage_wording %} +{%- set text = "This PR adds " ~ missing_lines_count ~ " statement" ~ (statements_count | pluralize) ~ " missing coverage to " ~ path ~ ". The file did not seem to exist on the base branch." -%} +{%- set message = missing_lines_count -%} +{%- endif -%} +{%- set color = message | get_evolution_color(up_is_good=false) -%} + + +{%- endmacro -%} + +{%- macro coverage_rate_badge(path, previous_percent_covered, previous_covered_statements_count, previous_statements_count, percent_covered, covered_statements_count, statements_count) -%} +{%- if previous_percent_covered is not none -%} +{%- set coverage_diff = percent_covered - previous_percent_covered -%} +{%- if coverage_diff > 0 -%} +{%- set text = "This PR adds " ~ ("{:.02f}".format(coverage_diff * 100)) ~ " percentage points to the coverage rate in " ~ path ~ ", taking it from " ~ previous_percent_covered | pct ~ " (" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ ") to " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%} +{%- elif coverage_diff < 0 -%} +{%- set text = "This PR removes " ~ ("{:.02f}".format(-coverage_diff * 100)) ~ " percentage points from the coverage rate in " ~ path ~ ", taking it from " ~ previous_percent_covered | pct ~ " (" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ ") to " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%} +{%- else -%} +{%- set text = "This PR doesn't change the coverage rate in " ~ path ~ ", which is " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%} +{%- endif -%} +{%- set color = coverage_diff | get_evolution_color() -%} +{%- set message = "(" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ " > " ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%} +{%- else -%} +{%- set text = "The coverage rate of " ~ path ~ " is " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ "). The file did not seem to exist on the base branch." -%} +{%- set message = "(" ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%} +{%- set color = percent_covered | x100 | get_badge_color -%} +{%- endif -%} + + +{%- endmacro -%} + +{%- macro diff_coverage_rate_badge(path, added_statements_count, covered_statements_count, percent_covered) -%} +{% if added_statements_count -%} +{% set text = "In this PR, " ~ (added_statements_count) ~ " new statements are added to " ~ path ~ ", " ~ covered_statements_count ~ " of which are covered (" ~ (percent_covered | pct) ~ ")." -%} +{% set label = (percent_covered | pct(precision=0)) -%} +{% set message = "(" ~ covered_statements_count ~ "/" ~ added_statements_count ~ ")" -%} +{%- set color = (percent_covered | x100 | get_badge_color()) -%} +{% else -%} +{% set text = "This PR does not seem to add statements to " ~ path ~ "." -%} +{% set label = "" -%} +{%- set color = "grey" -%} +{% set message = "N/A" -%} +{% endif -%} + + +{%- endmacro -%} + + +{# Individual file report #} +{%- block coverage_by_file -%} +{%- if not files -%} +_This PR does not seem to contain any modification to coverable code._ {%- else -%} -{% block diff_coverage_empty_wording -%} -_None of the new lines are part of the tested code. Therefore, there is no coverage data about them._ -{%- endblock diff_coverage_empty_wording %} -{%- endif %} - -{% block coverage_by_file -%} -{%if diff_coverage.files -%} -
-{% block coverage_by_file_summary_wording -%}Diff Coverage details (click to unfold){% endblock coverage_by_file_summary_wording -%} -{% for filename, diff_file_coverage in diff_coverage.files.items() -%} -{% block coverage_single_file scoped %} -{% block coverage_single_file_title scoped %}### {{ filename }}{% endblock coverage_single_file_title %} -{% block diff_coverage_single_file_wording scoped -%} -`{{ diff_file_coverage.percent_covered | pct }}` of new lines are covered (`{{ coverage.files[filename].info.percent_covered | pct }}` of the complete file). -{%- endblock diff_coverage_single_file_wording %} -{%- if diff_file_coverage.violation_lines -%} -{% block single_file_missing_lines_wording scoped -%} -{% set separator = joiner(", ") %} -Missing lines: {% for line in diff_file_coverage.violation_lines %}{{ separator() }}`{{ line }}`{% endfor %} -{%- endblock single_file_missing_lines_wording %} -{%- endif %} -{% endblock coverage_single_file -%} -{%- endfor %} +
Click to see where and how coverage changed + + + + +{%- for parent, files_in_folder in files|groupby(attribute="path.parent") -%} + + + +{%- for file in files_in_folder -%} +{%- set path = file.coverage.path -%} + + + +{#- Statements cell -#} +{%- block statements_badge_cell scoped -%} +{{- statements_badge( + path=path, + statements_count=file.coverage.info.num_statements, + previous_statements_count=(file.previous.info.num_statements if file.previous else none), +) -}} +{%- endblock statements_badge_cell-%} + +{#- Missing cell -#} +{%- block missing_lines_badge_cell scoped -%} +{{- missing_lines_badge( + path=path, + missing_lines_count=file.coverage.info.missing_lines, + previous_missing_lines_count=(file.previous.info.missing_lines if file.previous else none), +) -}} +{%- endblock missing_lines_badge_cell -%} + +{#- Coverage rate -#} +{%- block coverage_rate_badge_cell scoped -%} +{{- coverage_rate_badge( + path=path, + previous_percent_covered=(file.previous.info.percent_covered if file.previous else none), + previous_covered_statements_count=(file.previous.info.covered_lines if file.previous else none), + previous_statements_count=(file.previous.info.num_statements if file.previous else none), + percent_covered=file.coverage.info.percent_covered, + covered_statements_count=file.coverage.info.covered_lines, + statements_count=file.coverage.info.num_statements, +) -}} +{%- endblock coverage_rate_badge_cell -%} + +{#- Coverage of added lines -#} +{%- block diff_coverage_rate_badge_cell scoped -%} +{{- diff_coverage_rate_badge( + path=path, + added_statements_count=((file.diff.added_statements | length) if file.diff else none), + covered_statements_count=((file.diff.covered_statements | length) if file.diff else none), + percent_covered=(file.diff.percent_covered if file.diff else none) +) -}} +{%- endblock diff_coverage_rate_badge_cell -%} + +{#- Link to missing lines -#} +{%- block link_to_missing_diff_lines_cell scoped -%} + + +{%- endblock link_to_missing_diff_lines_cell -%} +{%- endfor -%} +{%- endfor -%} + + + + + + +{#- Statements cell -#} +{%- block statements_badge_total_cell scoped -%} +{{- statements_badge( + path="the whole project", + statements_count=coverage.info.num_statements, + previous_statements_count=(previous_coverage.info.num_statements if previous_coverage else none), +) -}} +{%- endblock statements_badge_total_cell -%} + +{#- Missing cell -#} +{%- block missing_lines_badge_total_cell scoped -%} +{{- missing_lines_badge( + path="the whole project", + missing_lines_count=coverage.info.missing_lines, + previous_missing_lines_count=(previous_coverage.info.missing_lines if previous_coverage else none), +) -}} +{%- endblock missing_lines_badge_total_cell -%} + +{#- Coverage rate -#} +{%- block coverage_rate_badge_total_cell scoped -%} +{{- coverage_rate_badge( + path="the whole project", + previous_percent_covered=(previous_coverage.info.percent_covered if previous_coverage else none), + previous_covered_statements_count=(previous_coverage.info.covered_lines if previous_coverage else none), + previous_statements_count=(previous_coverage.info.num_statements if previous_coverage else none), + percent_covered=coverage.info.percent_covered, + covered_statements_count=coverage.info.covered_lines, + statements_count=coverage.info.num_statements, +) -}} +{%- endblock coverage_rate_badge_total_cell -%} + +{# Coverage of added lines #} +{%- block diff_coverage_rate_badge_total_cell scoped -%} +{{- diff_coverage_rate_badge( + path="the whole project", + added_statements_count=diff_coverage.total_num_lines, + covered_statements_count=(diff_coverage.total_num_lines-diff_coverage.total_num_violations), + percent_covered=diff_coverage.total_percent_covered, +) -}} +{%- endblock diff_coverage_rate_badge_total_cell -%} + + + + +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  {{ parent }}
  {{ path.name }} + +{%- set comma = joiner() -%} +{%- for group in missing_diff_lines.get(path, []) -%} +{{- comma() -}} + + +{{- group.line_start -}} +{%- if group.line_start != group.line_end -%} +- +{{- group.line_end -}} +{%- endif -%} + + +{%- endfor -%} +
Project Total 
+ +{%- if max_files and count_files > max_files %} + +> [!NOTE] +> The report is truncated to {{ max_files }} files out of {{ count_files }}. To see the full report, please visit the workflow summary page. + +{% endif -%} + +{%- endif -%} +{%- endblock coverage_by_file -%} + +{%- block footer -%} + + +This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) + + + +{%- endblock footer -%}
-{%- endif %} -{%- endblock coverage_by_file %} -{{ marker }} + +{{ marker -}} diff --git a/coverage_comment/template_files/log.txt.j2 b/coverage_comment/template_files/log.txt.j2 index b8d252c2..affa2fae 100644 --- a/coverage_comment/template_files/log.txt.j2 +++ b/coverage_comment/template_files/log.txt.j2 @@ -1,4 +1,4 @@ -{% if subproject_id %} Coverage info for {{ subproject_id }}: +{% if subproject_id %}Coverage info for {{ subproject_id }}: {% endif -%} {% if is_public -%} diff --git a/pyproject.toml b/pyproject.toml index fa13cd9d..57cfedcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] addopts = """ --cov-report term-missing --cov-branch --cov-report html --cov-report term - --cov=coverage_comment -vv --strict-markers -rfE + --cov=coverage_comment --cov-context=test -vv --strict-markers -rfE --ignore=tests/end_to_end/repo """ testpaths = ["tests/unit", "tests/integration", "tests/end_to_end"] @@ -53,6 +53,9 @@ relative_files = true [tool.coverage.report] exclude_also = ["\\.\\.\\."] +[tool.coverage.html] +show_contexts = true + [tool.mypy] no_implicit_optional = true @@ -64,6 +67,7 @@ extend-select = [ "W", # pycodestyle warnings "RUF", # ruff ] +fixable = ["ALL"] extend-ignore = [ "E501", # line too long ] diff --git a/tests/conftest.py b/tests/conftest.py index 9399d5bc..c7f44601 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,208 +74,6 @@ def _(**kwargs): return _ -@pytest.fixture -def coverage_json(): - return { - "meta": { - "version": "1.2.3", - "timestamp": "2000-01-01T00:00:00", - "branch_coverage": True, - "show_contexts": False, - }, - "files": { - "codebase/code.py": { - "executed_lines": [1, 2, 5, 6, 9], - "summary": { - "covered_lines": 5, - "num_statements": 6, - "percent_covered": 75.0, - "missing_lines": 1, - "excluded_lines": 0, - "num_branches": 2, - "num_partial_branches": 1, - "covered_branches": 1, - "missing_branches": 1, - }, - "missing_lines": [7, 9], - "excluded_lines": [], - } - }, - "totals": { - "covered_lines": 5, - "num_statements": 6, - "percent_covered": 75.0, - "missing_lines": 1, - "excluded_lines": 0, - "num_branches": 2, - "num_partial_branches": 1, - "covered_branches": 1, - "missing_branches": 1, - }, - } - - -@pytest.fixture -def coverage_obj(): - return coverage_module.Coverage( - meta=coverage_module.CoverageMetadata( - version="1.2.3", - timestamp=datetime.datetime(2000, 1, 1), - branch_coverage=True, - show_contexts=False, - ), - info=coverage_module.CoverageInfo( - covered_lines=5, - num_statements=6, - percent_covered=decimal.Decimal("0.75"), - missing_lines=1, - excluded_lines=0, - num_branches=2, - num_partial_branches=1, - covered_branches=1, - missing_branches=1, - ), - files={ - pathlib.Path("codebase/code.py"): coverage_module.FileCoverage( - path=pathlib.Path("codebase/code.py"), - executed_lines=[1, 2, 5, 6, 9], - missing_lines=[7, 9], - excluded_lines=[], - info=coverage_module.CoverageInfo( - covered_lines=5, - num_statements=6, - percent_covered=decimal.Decimal("0.75"), - missing_lines=1, - excluded_lines=0, - num_branches=2, - num_partial_branches=1, - covered_branches=1, - missing_branches=1, - ), - ) - }, - ) - - -@pytest.fixture -def coverage_obj_no_branch(): - return coverage_module.Coverage( - meta=coverage_module.CoverageMetadata( - version="1.2.3", - timestamp=datetime.datetime(2000, 1, 1), - branch_coverage=False, - show_contexts=False, - ), - info=coverage_module.CoverageInfo( - covered_lines=5, - num_statements=6, - percent_covered=decimal.Decimal("0.75"), - missing_lines=1, - excluded_lines=0, - num_branches=None, - num_partial_branches=None, - covered_branches=None, - missing_branches=None, - ), - files={ - pathlib.Path("codebase/code.py"): coverage_module.FileCoverage( - path=pathlib.Path("codebase/code.py"), - executed_lines=[1, 2, 5, 6, 9], - missing_lines=[7], - excluded_lines=[], - info=coverage_module.CoverageInfo( - covered_lines=5, - num_statements=6, - percent_covered=decimal.Decimal("0.8333"), - missing_lines=1, - excluded_lines=0, - num_branches=None, - num_partial_branches=None, - covered_branches=None, - missing_branches=None, - ), - ) - }, - ) - - -@pytest.fixture -def coverage_obj_more_files(coverage_obj_no_branch): - coverage_obj_no_branch.files[ - pathlib.Path("codebase/other.py") - ] = coverage_module.FileCoverage( - path=pathlib.Path("codebase/other.py"), - executed_lines=[10, 11, 12], - missing_lines=[13], - excluded_lines=[], - info=coverage_module.CoverageInfo( - covered_lines=3, - num_statements=4, - percent_covered=decimal.Decimal("0.75"), - missing_lines=1, - excluded_lines=0, - num_branches=None, - num_partial_branches=None, - covered_branches=None, - missing_branches=None, - ), - ) - return coverage_obj_no_branch - - -@pytest.fixture -def make_coverage_obj(coverage_obj_more_files): - def f(**kwargs): - obj = coverage_obj_more_files - for key, value in kwargs.items(): - vars(obj.files[pathlib.Path(key)]).update(value) - return obj - - return f - - -@pytest.fixture -def diff_coverage_obj(): - return coverage_module.DiffCoverage( - total_num_lines=5, - total_num_violations=1, - total_percent_covered=decimal.Decimal("0.8"), - num_changed_lines=39, - files={ - pathlib.Path("codebase/code.py"): coverage_module.FileDiffCoverage( - path=pathlib.Path("codebase/code.py"), - percent_covered=decimal.Decimal("0.8"), - missing_lines=[7, 9], - added_lines=[7, 8, 9], - ) - }, - ) - - -@pytest.fixture -def diff_coverage_obj_many_missing_lines(): - return coverage_module.DiffCoverage( - total_num_lines=5, - total_num_violations=1, - total_percent_covered=decimal.Decimal("0.8"), - num_changed_lines=39, - files={ - pathlib.Path("codebase/code.py"): coverage_module.FileDiffCoverage( - path=pathlib.Path("codebase/code.py"), - percent_covered=decimal.Decimal("0.8"), - missing_lines=[7, 9], - added_lines=[7, 8, 9], - ), - pathlib.Path("codebase/main.py"): coverage_module.FileDiffCoverage( - path=pathlib.Path("codebase/code.py"), - percent_covered=decimal.Decimal("0.8"), - missing_lines=[1, 2, 8, 17], - added_lines=[1, 2, 3, 4, 5, 6, 7, 8, 17], - ), - }, - ) - - @pytest.fixture def session(is_failed): """ @@ -465,3 +263,296 @@ def f(): yield f _is_failed.clear() + + +@pytest.fixture +def make_coverage(): + def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: + current_file = None + coverage_obj = coverage_module.Coverage( + meta=coverage_module.CoverageMetadata( + version="1.2.3", + timestamp=datetime.datetime(2000, 1, 1), + branch_coverage=True, + show_contexts=False, + ), + info=coverage_module.CoverageInfo( + covered_lines=0, + num_statements=0, + percent_covered=decimal.Decimal("1.0"), + missing_lines=0, + excluded_lines=0, + num_branches=0 if has_branches else None, + num_partial_branches=0 if has_branches else None, + covered_branches=0 if has_branches else None, + missing_branches=0 if has_branches else None, + ), + files={}, + ) + line_number = 0 + # (we start at 0 because the first line will be empty for readabilty) + for line in code.splitlines()[1:]: + line = line.strip() + if line.startswith("# file: "): + current_file = pathlib.Path(line.split("# file: ")[1]) + continue + assert current_file, (line, current_file, code) + line_number += 1 + if coverage_obj.files.get(current_file) is None: + coverage_obj.files[current_file] = coverage_module.FileCoverage( + path=current_file, + executed_lines=[], + missing_lines=[], + excluded_lines=[], + info=coverage_module.CoverageInfo( + covered_lines=0, + num_statements=0, + percent_covered=decimal.Decimal("1.0"), + missing_lines=0, + excluded_lines=0, + num_branches=0 if has_branches else None, + num_partial_branches=0 if has_branches else None, + covered_branches=0 if has_branches else None, + missing_branches=0 if has_branches else None, + ), + ) + if set(line.split()) & { + "covered", + "missing", + "excluded", + "partial", + "branch", + }: + coverage_obj.files[current_file].info.num_statements += 1 + coverage_obj.info.num_statements += 1 + if "covered" in line or "partial" in line: + coverage_obj.files[current_file].executed_lines.append(line_number) + coverage_obj.files[current_file].info.covered_lines += 1 + coverage_obj.info.covered_lines += 1 + elif "missing" in line: + coverage_obj.files[current_file].missing_lines.append(line_number) + coverage_obj.files[current_file].info.missing_lines += 1 + coverage_obj.info.missing_lines += 1 + elif "excluded" in line: + coverage_obj.files[current_file].excluded_lines.append(line_number) + coverage_obj.files[current_file].info.excluded_lines += 1 + coverage_obj.info.excluded_lines += 1 + + if has_branches and "branch" in line: + coverage_obj.files[current_file].info.num_branches += 1 + coverage_obj.info.num_branches += 1 + if "branch partial" in line: + coverage_obj.files[current_file].info.num_partial_branches += 1 + coverage_obj.info.num_partial_branches += 1 + elif "branch covered" in line: + coverage_obj.files[current_file].info.covered_branches += 1 + coverage_obj.info.covered_branches += 1 + elif "branch missing" in line: + coverage_obj.files[current_file].info.missing_branches += 1 + coverage_obj.info.missing_branches += 1 + + info = coverage_obj.files[current_file].info + coverage_obj.files[ + current_file + ].info.percent_covered = coverage_module.compute_coverage( + num_covered=info.covered_lines, + num_total=info.num_statements, + ) + + info = coverage_obj.info + coverage_obj.info.percent_covered = coverage_module.compute_coverage( + num_covered=info.covered_lines, + num_total=info.num_statements, + ) + + return coverage_obj + + return _ + + +@pytest.fixture +def make_diff_coverage(): + return coverage_module.get_diff_coverage_info + + +@pytest.fixture +def make_coverage_and_diff(make_coverage, make_diff_coverage): + def _(code: str) -> tuple[coverage_module.Coverage, coverage_module.DiffCoverage]: + added_lines: dict[pathlib.Path, list[int]] = {} + new_code = "" + current_file = None + # (we start at 0 because the first line will be empty for readabilty) + line_number = 0 + for line in code.splitlines()[1:]: + line = line.strip() + if line.startswith("# file: "): + new_code += line + "\n" + current_file = pathlib.Path(line.split("# file: ")[1]) + line_number = 0 + continue + assert current_file + line_number += 1 + + if line.startswith("+ "): + added_lines.setdefault(current_file, []).append(line_number) + new_code += line[2:] + "\n" + else: + new_code += line + "\n" + + coverage = make_coverage("\n" + new_code) + return coverage, make_diff_coverage(added_lines=added_lines, coverage=coverage) + + return _ + + +@pytest.fixture +def coverage_code(): + return """ + # file: codebase/code.py + 1 covered + 2 covered + 3 covered + 4 + 5 branch partial + 6 missing + 7 + 8 missing + 9 + 10 branch missing + 11 missing + 12 + 13 branch covered + 14 covered + """ + + +@pytest.fixture +def coverage_json(): + return { + "meta": { + "version": "1.2.3", + "timestamp": "2000-01-01T00:00:00", + "branch_coverage": True, + "show_contexts": False, + }, + "files": { + "codebase/code.py": { + "executed_lines": [1, 2, 3, 5, 13, 14], + "summary": { + "covered_lines": 6, + "num_statements": 10, + "percent_covered": 60.0, + "missing_lines": 4, + "excluded_lines": 0, + "num_branches": 3, + "num_partial_branches": 1, + "covered_branches": 1, + "missing_branches": 1, + }, + "missing_lines": [6, 8, 10, 11], + "excluded_lines": [], + } + }, + "totals": { + "covered_lines": 6, + "num_statements": 10, + "percent_covered": 60.0, + "missing_lines": 4, + "excluded_lines": 0, + "num_branches": 3, + "num_partial_branches": 1, + "covered_branches": 1, + "missing_branches": 1, + }, + } + + +@pytest.fixture +def coverage_obj(make_coverage, coverage_code): + return make_coverage(coverage_code) + + +@pytest.fixture +def coverage_obj_no_branch_code(): + return """ + # file: codebase/code.py + covered + covered + missing + + covered + missing + + missing + missing + covered + """ + + +@pytest.fixture +def coverage_obj_no_branch(make_coverage, coverage_obj_no_branch_code): + return make_coverage(coverage_obj_no_branch_code, has_branches=False) + + +@pytest.fixture +def coverage_obj_more_files(make_coverage): + return make_coverage( + """ + # file: codebase/code.py + covered + covered + covered + + branch partial + missing + + missing + + branch missing + missing + + branch covered + covered + # file: codebase/other.py + + + missing + covered + missing + missing + + missing + covered + covered + """ + ) + + +@pytest.fixture +def make_coverage_obj(coverage_obj_more_files): + def f(**kwargs): + obj = coverage_obj_more_files + for key, value in kwargs.items(): + vars(obj.files[pathlib.Path(key)]).update(value) + return obj + + return f + + +@pytest.fixture +def diff_coverage_obj(coverage_obj, make_diff_coverage): + return make_diff_coverage( + added_lines={pathlib.Path("codebase/code.py"): [3, 4, 5, 6, 7, 8, 9, 12]}, + coverage=coverage_obj, + ) + + +@pytest.fixture +def diff_coverage_obj_more_files(coverage_obj_more_files, make_diff_coverage): + return make_diff_coverage( + added_lines={ + pathlib.Path("codebase/code.py"): [3, 4, 5, 6, 7, 8, 9, 12], + pathlib.Path("codebase/other.py"): [1, 2, 3, 4, 5, 6, 7, 8, 17], + }, + coverage=coverage_obj_more_files, + ) diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py index 30137198..5ca63c2f 100644 --- a/tests/end_to_end/test_all.py +++ b/tests/end_to_end/test_all.py @@ -125,7 +125,6 @@ def test_public_repo( "--jq=.comments[0].body", fail_value="\n", ) - assert ":arrow_up:" in comment assert "## Coverage report (my-great-project)" in comment assert ( "This comment was produced by python-coverage-comment-action (id: my-great-project)" @@ -184,7 +183,7 @@ def test_public_repo( fail_value="\n", ) - assert ":arrow_up:" in ext_comment + assert "-brightgreen.svg" in ext_comment @pytest.mark.repo_suffix("private") @@ -275,7 +274,7 @@ def test_private_repo( "--jq=.comments[0].body", fail_value="\n", ) - assert ":arrow_up:" in comment + assert "-brightgreen.svg" in comment # Let's merge the PR and see if everything works fine gh_me("pr", "merge", "1", "--merge") diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py index 066a9656..ca2d5bad 100644 --- a/tests/integration/test_github.py +++ b/tests/integration/test_github.py @@ -346,13 +346,13 @@ def test_annotations(capsys): annotation_type="warning", annotations=[ (pathlib.Path("codebase/code.py"), 1, 3), - (pathlib.Path("codebase/main.py"), 5, 5), + (pathlib.Path("codebase/other.py"), 5, 5), ], ) expected = """::group::Annotations of lines with missing coverage ::warning file=codebase/code.py,line=1,endLine=3,title=Missing coverage::Missing coverage on lines 1-3 -::warning file=codebase/main.py,line=5,endLine=5,title=Missing coverage::Missing coverage on line 5 +::warning file=codebase/other.py,line=5,endLine=5,title=Missing coverage::Missing coverage on line 5 ::endgroup::""" output = capsys.readouterr() assert output.err.strip() == expected diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 17995c01..a2419e90 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -192,11 +192,16 @@ def checker(payload): comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text() assert comment == comment_file assert comment == summary_file.read_text() - assert "Coverage data for the default branch was not found." in comment - assert "The coverage rate is `77.77%`" in comment - assert "`75%` of new lines are covered." in comment assert ( - "### foo.py\n`75%` of new lines are covered (`77.77%` of the complete file)" + "Coverage for the whole project is 77.77%. Previous coverage rate is not available" + in comment + ) + assert ( + "In this PR, 4 new statements are added to the whole project, 3 of which are covered (75%)." + in comment + ) + assert ( + "https://github.com/py-cov-action/foobar/pull/2/files#diff-b08fd7a517303ab07cfa211f74d03c1a4c2e64b3b0656d84ff32ecb449b785d2" in comment ) assert ( @@ -270,7 +275,10 @@ def checker(payload): comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text() assert comment == comment_file assert comment == summary_file.read_text() - assert "Coverage evolution disabled because this PR targets" in comment + assert ( + "Previous coverage rate is not available, cannot report on evolution." + in comment + ) def test_action__pull_request__post_comment( @@ -324,7 +332,8 @@ def checker(payload): assert result == 0 assert not pathlib.Path("python-coverage-comment-action.txt").exists() - assert "The coverage rate went from `30%` to `77.77%` :arrow_up:" in comment + assert "Coverage for the whole project went from 30% to 77.77%" in comment + assert comment.count(" 99%", + "", + ), + ], +) +def test_get_static_badge_url__error(label, message, color): + with pytest.raises(ValueError): + badge.get_static_badge_url(label=label, message=message, color=color) + + def test_get_endpoint_url(): url = badge.get_endpoint_url(endpoint_url="https://foo") expected = "https://img.shields.io/endpoint?url=https://foo" diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py index ca45b07d..6ea249a2 100644 --- a/tests/unit/test_coverage.py +++ b/tests/unit/test_coverage.py @@ -9,6 +9,20 @@ from coverage_comment import coverage, subprocess +def test_diff_violations(make_coverage_and_diff): + _, diff = make_coverage_and_diff( + """ + # file: a.py + + 1 missing + 2 missing + + 3 missing + 4 covered + + 5 covered + """ + ) + assert diff.files[pathlib.Path("a.py")].violation_lines == [1, 3] + + @pytest.mark.parametrize( "num_covered, num_total, expected_coverage", [ @@ -137,7 +151,9 @@ def test_generate_coverage_markdown(mocker): pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/code.py"), percent_covered=decimal.Decimal("0.5"), - missing_lines=[3], + added_statements=[1, 3], + covered_statements=[1], + missing_statements=[3], added_lines=[1, 3], ) }, @@ -174,7 +190,9 @@ def test_generate_coverage_markdown(mocker): pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/code.py"), percent_covered=decimal.Decimal("1"), - missing_lines=[], + added_statements=[], + covered_statements=[], + missing_statements=[], added_lines=[4, 5, 6], ) }, @@ -207,13 +225,17 @@ def test_generate_coverage_markdown(mocker): pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/code.py"), percent_covered=decimal.Decimal("1"), - missing_lines=[], + added_statements=[5, 6], + covered_statements=[5, 6], + missing_statements=[], added_lines=[4, 5, 6], ), pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/other.py"), percent_covered=decimal.Decimal("0.5"), - missing_lines=[13], + added_statements=[10, 13], + covered_statements=[10], + missing_statements=[13], added_lines=[10, 13], ), }, diff --git a/tests/unit/test_diff_grouper.py b/tests/unit/test_diff_grouper.py new file mode 100644 index 00000000..404d56c7 --- /dev/null +++ b/tests/unit/test_diff_grouper.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pathlib + +from coverage_comment import diff_grouper, groups + + +def test_group_annotations(coverage_obj, diff_coverage_obj): + result = diff_grouper.get_diff_missing_groups( + coverage=coverage_obj, diff_coverage=diff_coverage_obj + ) + + assert list(result) == [ + groups.Group(file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8), + ] + + +def test_group_annotations_more_files( + coverage_obj_more_files, diff_coverage_obj_more_files +): + result = diff_grouper.get_diff_missing_groups( + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + ) + + assert list(result) == [ + groups.Group(file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8), + groups.Group( + file=pathlib.Path("codebase/other.py"), line_start=17, line_end=17 + ), + ] diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py index e0bb191d..0fe36d6d 100644 --- a/tests/unit/test_files.py +++ b/tests/unit/test_files.py @@ -1,6 +1,7 @@ from __future__ import annotations import decimal +import json import pathlib from coverage_comment import files @@ -65,11 +66,26 @@ def test_compute_datafile(): def test_parse_datafile(): - assert files.parse_datafile(contents="""{"coverage": 12.34}""") == decimal.Decimal( - "0.1234" + assert files.parse_datafile(contents="""{"coverage": 12.34}""") == ( + None, + decimal.Decimal("0.1234"), ) +def test_parse_datafile__previous(coverage_json, coverage_obj): + result = files.parse_datafile( + contents=json.dumps( + { + "coverage": 12.34, + "raw_data": coverage_json, + "coverage_path": ".", + } + ) + ) + + assert result == (coverage_obj, decimal.Decimal("0.1234")) + + def test_get_urls(): def getter(path): return f"https://{path}" diff --git a/tests/unit/test_annotations.py b/tests/unit/test_groups.py similarity index 79% rename from tests/unit/test_annotations.py rename to tests/unit/test_groups.py index 4b154d43..7edcd254 100644 --- a/tests/unit/test_annotations.py +++ b/tests/unit/test_groups.py @@ -1,10 +1,8 @@ from __future__ import annotations -import pathlib - import pytest -from coverage_comment import annotations +from coverage_comment import groups @pytest.mark.parametrize( @@ -49,19 +47,7 @@ ], ) def test_compute_contiguous_groups(values, separators, joiners, expected): - result = annotations.compute_contiguous_groups( - values=values, separators=separators, joiners=joiners + result = groups.compute_contiguous_groups( + values=values, separators=separators, joiners=joiners, max_gap=3 ) assert result == expected - - -def test_group_annotations(coverage_obj, diff_coverage_obj): - result = annotations.group_annotations( - coverage=coverage_obj, diff_coverage=diff_coverage_obj - ) - - assert list(result) == [ - annotations.Annotation( - file=pathlib.Path("codebase/code.py"), line_start=7, line_end=9 - ) - ] diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py index b62cdafe..c9ebd89d 100644 --- a/tests/unit/test_template.py +++ b/tests/unit/test_template.py @@ -1,6 +1,5 @@ from __future__ import annotations -import datetime import decimal import pathlib @@ -10,12 +9,26 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj): + files, total = template.select_files( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + previous_coverage=coverage_obj, + max_files=25, + ) result = ( template.get_comment_markdown( coverage=coverage_obj, + previous_coverage=coverage_obj, diff_coverage=diff_coverage_obj, + files=files, + count_files=total, + max_files=25, previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("100"), + minimum_orange=decimal.Decimal("70"), marker="", + repo_name="org/repo", + pr_number=1, base_template=""" {{ previous_coverage_rate | pct }} {{ coverage.info.percent_covered | pct }} @@ -31,22 +44,30 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj): .split(maxsplit=4) ) - expected = [ - "92%", - "75%", - "80%", - "bar", - "", - ] + expected = ["92%", "60%", "50%", "bar", ""] assert result == expected def test_template(coverage_obj, diff_coverage_obj): + files, total = template.select_files( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + previous_coverage=None, + max_files=25, + ) result = template.get_comment_markdown( coverage=coverage_obj, diff_coverage=diff_coverage_obj, + previous_coverage=None, previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("79"), + minimum_orange=decimal.Decimal("40"), + files=files, + count_files=total, + max_files=25, + repo_name="org/repo", + pr_number=5, base_template=template.read_template_file("comment.md.j2"), marker="", subproject_id="foo", @@ -54,190 +75,313 @@ def test_template(coverage_obj, diff_coverage_obj): {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %} """, ) + print(result) expected = """## Coverage report (foo) -The coverage rate went from `92%` to `75%` :sob: -The branch rate is `50%`. -`80%` of new lines are covered. -
-Diff Coverage details (click to unfold) +
Click to see where and how coverage changed + + + + + + + + + + + + +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py6-8
Project Total 
-### codebase/code.py -`80%` of new lines are covered (`75%` of the complete file). -Missing lines: `7`, `9` +This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) + +
-
""" assert result == expected -def test_template_full(): - cov = coverage.Coverage( - meta=coverage.CoverageMetadata( - version="1.2.3", - timestamp=datetime.datetime(2000, 1, 1), - branch_coverage=True, - show_contexts=False, - ), - info=coverage.CoverageInfo( - covered_lines=6, - num_statements=6, - percent_covered=decimal.Decimal("1"), - missing_lines=0, - excluded_lines=0, - num_branches=2, - num_partial_branches=0, - covered_branches=2, - missing_branches=0, - ), - files={ - pathlib.Path("codebase/code.py"): coverage.FileCoverage( - path=pathlib.Path("codebase/code.py"), - executed_lines=[1, 2, 5, 6, 9], - missing_lines=[], - excluded_lines=[], - info=coverage.CoverageInfo( - covered_lines=5, - num_statements=6, - percent_covered=decimal.Decimal("5") / decimal.Decimal("6"), - missing_lines=1, - excluded_lines=0, - num_branches=2, - num_partial_branches=0, - covered_branches=2, - missing_branches=0, - ), - ), - pathlib.Path("codebase/other.py"): coverage.FileCoverage( - path=pathlib.Path("codebase/other.py"), - executed_lines=[1, 2, 3], - missing_lines=[], - excluded_lines=[], - info=coverage.CoverageInfo( - covered_lines=6, - num_statements=6, - percent_covered=decimal.Decimal("1"), - missing_lines=0, - excluded_lines=0, - num_branches=2, - num_partial_branches=0, - covered_branches=2, - missing_branches=0, - ), - ), - }, +def test_template_full(make_coverage, make_coverage_and_diff): + previous_cov = make_coverage( + """ + # file: codebase/code.py + 1 covered + 2 covered + 3 + 4 missing + 5 covered + 6 covered + 7 + 8 + 9 covered + # file: codebase/other.py + 1 covered + 2 covered + 3 covered + 4 covered + 5 covered + 6 covered + # file: codebase/third.py + 1 covered + 2 covered + 3 covered + 4 covered + 5 covered + 6 missing + 7 missing + """ + ) + cov, diff_cov = make_coverage_and_diff( + """ + # file: codebase/code.py + 1 covered + 2 covered + 3 + 4 + 5 covered + 6 covered + 7 + 8 + 9 covered + 10 + 11 + + 12 missing + + 13 missing + + 14 missing + + 15 covered + + 16 covered + + 17 + + 18 + + 19 + + 20 + + 21 + + 22 missing + # file: codebase/other.py + 1 covered + 2 covered + 3 covered + # file: codebase/third.py + 1 covered + 2 covered + 3 covered + 4 covered + 5 covered + 6 covered + 7 covered + """ ) - diff_cov = coverage.DiffCoverage( - total_num_lines=6, - total_num_violations=0, - total_percent_covered=decimal.Decimal("1"), - num_changed_lines=39, - files={ - pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( - path=pathlib.Path("codebase/code.py"), - percent_covered=decimal.Decimal("0.5"), - missing_lines=[5], - added_lines=[5], - ), - pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage( - path=pathlib.Path("codebase/other.py"), - percent_covered=decimal.Decimal("1"), - missing_lines=[], - added_lines=[4, 5, 6], - ), - }, + files, total = template.select_files( + coverage=cov, + diff_coverage=diff_cov, + previous_coverage=previous_cov, + max_files=25, ) result = template.get_comment_markdown( coverage=cov, diff_coverage=diff_cov, - previous_coverage_rate=decimal.Decimal("1.0"), + previous_coverage=previous_cov, + files=files, + count_files=total, + max_files=25, + previous_coverage_rate=decimal.Decimal("11") / decimal.Decimal("12"), + minimum_green=decimal.Decimal("100"), + minimum_orange=decimal.Decimal("70"), marker="", + repo_name="org/repo", + pr_number=12, base_template=template.read_template_file("comment.md.j2"), ) expected = """## Coverage report -The coverage rate went from `100%` to `100%` :arrow_right: -The branch rate is `100%`. -`100%` of new lines are covered. -
-Diff Coverage details (click to unfold) +
Click to see where and how coverage changed + + + + + + + + + + + -### codebase/code.py -`50%` of new lines are covered (`83.33%` of the complete file). -Missing lines: `5` + + + + -### codebase/other.py -`100%` of new lines are covered (`100%` of the complete file). + + + +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py12-14, 22
  other.py
  third.py
Project Total 
+ +This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) + +
-
""" + print(result) assert result == expected -def test_template__no_new_lines_with_coverage(coverage_obj): - diff_cov = coverage.DiffCoverage( - total_num_lines=0, - total_num_violations=0, - total_percent_covered=decimal.Decimal("1"), - num_changed_lines=39, - files={}, +def test_template__no_previous(coverage_obj_no_branch, diff_coverage_obj): + files, total = template.select_files( + coverage=coverage_obj_no_branch, + diff_coverage=diff_coverage_obj, + previous_coverage=None, + max_files=25, ) - result = template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_cov, - previous_coverage_rate=decimal.Decimal("1.0"), + coverage=coverage_obj_no_branch, + diff_coverage=diff_coverage_obj, + previous_coverage_rate=None, + previous_coverage=None, + files=files, + count_files=total, + max_files=25, + minimum_green=decimal.Decimal("100"), + minimum_orange=decimal.Decimal("70"), marker="", + repo_name="org/repo", + pr_number=3, base_template=template.read_template_file("comment.md.j2"), ) expected = """## Coverage report -The coverage rate went from `100%` to `75%` :arrow_down: -The branch rate is `50%`. -_None of the new lines are part of the tested code. Therefore, there is no coverage data about them._ +
Click to see where and how coverage changed + + + + + + + + + + + + +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py6-8
Project Total 
+ +This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) + +
""" + print(result) assert result == expected -def test_template__no_branch_no_previous(coverage_obj_no_branch, diff_coverage_obj): +def test_template__max_files(coverage_obj_more_files, diff_coverage_obj_more_files): + files, total = template.select_files( + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + previous_coverage=None, + max_files=25, + ) result = template.get_comment_markdown( - coverage=coverage_obj_no_branch, - diff_coverage=diff_coverage_obj, - previous_coverage_rate=None, - marker="", + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + previous_coverage=None, + files=files, + count_files=total, + previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("79"), + minimum_orange=decimal.Decimal("40"), + repo_name="org/repo", + pr_number=5, + max_files=1, base_template=template.read_template_file("comment.md.j2"), + marker="", + subproject_id="foo", + custom_template="""{% extends "base" %} + {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %} + """, ) - expected = """## Coverage report -> [!NOTE] -> Coverage data for the default branch was not found. -> This usually happens when the action has not run on the default -> branch yet, for example right after deploying it into the workflows. + print(result) -The coverage rate is `75%`. + assert "The report is truncated to 1 files out of 2." in result -`80%` of new lines are covered. -
-Diff Coverage details (click to unfold) +def test_template__no_max_files(coverage_obj_more_files, diff_coverage_obj_more_files): + files, total = template.select_files( + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + previous_coverage=None, + max_files=25, + ) + result = template.get_comment_markdown( + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + previous_coverage=None, + files=files, + count_files=total, + previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("79"), + minimum_orange=decimal.Decimal("40"), + repo_name="org/repo", + pr_number=5, + max_files=None, + base_template=template.read_template_file("comment.md.j2"), + marker="", + subproject_id="foo", + custom_template="""{% extends "base" %} + {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %} + """, + ) + print(result) -### codebase/code.py -`80%` of new lines are covered (`83.33%` of the complete file). -Missing lines: `7`, `9` + assert "The report is truncated" not in result + assert "code.py" in result + assert "other.py" in result -
-""" - assert result == expected + +def test_template__no_files(coverage_obj, diff_coverage_obj_more_files): + diff_coverage = coverage.DiffCoverage( + total_num_lines=0, + total_num_violations=0, + total_percent_covered=decimal.Decimal("1"), + num_changed_lines=0, + files={}, + ) + result = template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage, + previous_coverage=coverage_obj, + files=[], + count_files=0, + previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("79"), + minimum_orange=decimal.Decimal("40"), + repo_name="org/repo", + pr_number=5, + max_files=25, + base_template=template.read_template_file("comment.md.j2"), + marker="", + subproject_id="foo", + custom_template="""{% extends "base" %} + {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %} + """, + ) + print(result) + + assert ( + "_This PR does not seem to contain any modification to coverable code." + in result + ) + assert "code.py" not in result + assert "other.py" not in result def test_read_template_file(): assert template.read_template_file("comment.md.j2").startswith( - "{% block title %}## Coverage report{% if subproject_id %}" + "{%- block title -%}## Coverage report{%- if subproject_id %}" ) @@ -245,8 +389,16 @@ def test_template__no_marker(coverage_obj, diff_coverage_obj): with pytest.raises(template.MissingMarker): template.get_comment_markdown( coverage=coverage_obj, + previous_coverage=None, + files=[], + count_files=0, + max_files=25, diff_coverage=diff_coverage_obj, previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("100"), + minimum_orange=decimal.Decimal("70"), + repo_name="org/repo", + pr_number=1, base_template=template.read_template_file("comment.md.j2"), marker="", custom_template="""foo bar""", @@ -257,8 +409,16 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj): with pytest.raises(template.TemplateError): template.get_comment_markdown( coverage=coverage_obj, + previous_coverage=None, diff_coverage=diff_coverage_obj, + files=[], + count_files=0, + max_files=25, previous_coverage_rate=decimal.Decimal("0.92"), + minimum_green=decimal.Decimal("100"), + minimum_orange=decimal.Decimal("70"), + repo_name="org/repo", + pr_number=1, base_template=template.read_template_file("comment.md.j2"), marker="", custom_template="""{% extends "foo" %}""", @@ -268,16 +428,63 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj): @pytest.mark.parametrize( "value, displayed_coverage", [ - ("0.83", "83%"), - ("0.99999", "99.99%"), - ("0.00001", "0%"), - ("0.0501", "5.01%"), - ("1", "100%"), - ("0.8392", "83.92%"), + (decimal.Decimal("0.83"), "83%"), + (decimal.Decimal("0.99999"), "99.99%"), + (decimal.Decimal("0.00001"), "0%"), + (decimal.Decimal("0.0501"), "5.01%"), + (decimal.Decimal("1"), "100%"), + (decimal.Decimal("0.2"), "20%"), + (decimal.Decimal("0.8392"), "83.92%"), ], ) def test_pct(value, displayed_coverage): - assert template.pct(decimal.Decimal(value)) == displayed_coverage + assert template.pct(value) == displayed_coverage + + +@pytest.mark.parametrize( + "number, singular, plural, expected", + [ + (1, "", "s", ""), + (2, "", "s", "s"), + (0, "", "s", "s"), + (1, "y", "ies", "y"), + (2, "y", "ies", "ies"), + ], +) +def test_pluralize(number, singular, plural, expected): + assert ( + template.pluralize(number=number, singular=singular, plural=plural) == expected + ) + + +@pytest.mark.parametrize( + "filepath, lines, expected", + [ + ( + pathlib.Path("tests/conftest.py"), + None, + "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-e52e4ddd58b7ef887ab03c04116e676f6280b824ab7469d5d3080e5cba4f2128", + ), + ( + pathlib.Path("main.py"), + (12, 15), + "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-b10564ab7d2c520cdd0243874879fb0a782862c3c902ab535faabe57d5a505e1R12-R15", + ), + ( + pathlib.Path("codebase/other.py"), + (22, 22), + "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-30cad827f61772ec66bb9ef8887058e6d8443a2afedb331d800feaa60228a26eR22-R22", + ), + ], +) +def test_get_file_url(filepath, lines, expected): + result = template.get_file_url( + filename=filepath, + lines=lines, + repo_name="py-cov-action/python-coverage-comment-action", + pr_number=33, + ) + assert result == expected def test_uptodate(): @@ -296,3 +503,213 @@ def test_uptodate(): ) def test_get_marker(marker_id, result): assert template.get_marker(marker_id=marker_id) == result + + +@pytest.mark.parametrize( + "previous_code, current_code_and_diff, max_files, expected_files, expected_total", + [ + pytest.param( + """ + # file: a.py + 1 covered + """, + """ + # file: a.py + 1 covered + """, + 2, + [], + 0, + id="unmodified", + ), + pytest.param( + """ + # file: a.py + 1 covered + """, + """ + # file: a.py + 1 + 2 covered + """, + 2, + [], + 0, + id="info_did_not_change", + ), + pytest.param( + """ + # file: a.py + 1 covered + """, + """ + # file: a.py + 1 missing + """, + 2, + ["a.py"], + 1, + id="info_did_change", + ), + pytest.param( + """ + # file: a.py + 1 covered + """, + """ + # file: a.py + + 1 covered + """, + 2, + ["a.py"], + 1, + id="with_diff", + ), + pytest.param( + """ + # file: b.py + 1 covered + # file: a.py + 1 covered + """, + """ + # file: b.py + + 1 covered + # file: a.py + + 1 covered + """, + 2, + ["a.py", "b.py"], + 2, + id="ordered", + ), + pytest.param( + """ + # file: a.py + 1 covered + # file: b.py + 1 covered + """, + """ + # file: a.py + 1 covered + 2 covered + # file: b.py + 1 missing + """, + 1, + ["b.py"], + 2, + id="truncated", + ), + pytest.param( + """ + # file: a.py + 1 covered + # file: c.py + 1 covered + # file: b.py + 1 covered + """, + """ + # file: a.py + + 1 covered + # file: c.py + 1 missing + # file: b.py + 1 missing + """, + 2, + ["b.py", "c.py"], + 3, + id="truncated_and_ordered", + ), + pytest.param( + """ + # file: a.py + 1 covered + # file: b.py + 1 covered + """, + """ + # file: a.py + 1 covered + 2 covered + # file: b.py + 1 missing + """, + None, + ["a.py", "b.py"], + 2, + id="max_none", + ), + ], +) +def test_select_files( + make_coverage, + make_coverage_and_diff, + previous_code, + current_code_and_diff, + max_files, + expected_files, + expected_total, +): + previous_cov = make_coverage(previous_code) + cov, diff_cov = make_coverage_and_diff(current_code_and_diff) + + files, total = template.select_files( + coverage=cov, + diff_coverage=diff_cov, + previous_coverage=previous_cov, + max_files=max_files, + ) + assert [str(e.path) for e in files] == expected_files + assert total == expected_total + + +def test_select_files__no_previous( + make_coverage_and_diff, +): + cov, diff_cov = make_coverage_and_diff( + """ + # file: a.py + 1 covered + + 1 missing + """ + ) + + files, total = template.select_files( + coverage=cov, + diff_coverage=diff_cov, + previous_coverage=None, + max_files=1, + ) + assert [str(e.path) for e in files] == ["a.py"] + assert total == 1 + + +def test_get_readme_markdown(): + result = template.get_readme_markdown( + is_public=True, + readme_url="https://example.com", + markdown_report="...markdown report...", + direct_image_url="https://example.com/direct.png", + html_report_url="https://example.com/report.html", + dynamic_image_url="https://example.com/dynamic.png", + endpoint_image_url="https://example.com/endpoint.png", + subproject_id="foo", + ) + assert result.startswith("# Repository Coverage (foo)") + + +def test_get_log_message(): + result = template.get_log_message( + is_public=True, + readme_url="https://example.com", + direct_image_url="https://example.com/direct.png", + html_report_url="https://example.com/report.html", + dynamic_image_url="https://example.com/dynamic.png", + endpoint_image_url="https://example.com/endpoint.png", + subproject_id="foo", + ) + assert result.startswith("Coverage info for foo:")