From eea7cbaa50bea14a72013dfe24afcd90a225695a Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Fri, 29 Dec 2023 14:38:23 +0100 Subject: [PATCH] WIP --- action.yml | 9 + coverage_comment/badge.py | 8 +- coverage_comment/coverage.py | 28 +- coverage_comment/diff_grouper.py | 69 +- coverage_comment/groups.py | 63 ++ coverage_comment/main.py | 10 + coverage_comment/settings.py | 1 + coverage_comment/template.py | 103 +-- coverage_comment/template_files/comment.md.j2 | 260 ++++--- coverage_comment/template_files/log.txt.j2 | 2 +- pyproject.toml | 5 +- tests/conftest.py | 495 +++++++------ tests/integration/test_github.py | 4 +- tests/integration/test_main.py | 22 +- tests/unit/test_badge.py | 43 +- tests/unit/test_coverage.py | 14 +- tests/unit/test_diff_grouper.py | 16 +- tests/unit/test_files.py | 5 +- tests/unit/test_template.py | 682 ++++++++++++++---- 19 files changed, 1196 insertions(+), 643 deletions(-) create mode 100644 coverage_comment/groups.py diff --git a/action.yml b/action.yml index 42249d1b..82bf5d62 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,15 @@ 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. + 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 1824d940..9871932d 100644 --- a/coverage_comment/badge.py +++ b/coverage_comment/badge.py @@ -26,8 +26,8 @@ def get_badge_color( def get_evolution_badge_color( delta: decimal.Decimal | int, - up_is_good: bool, - neutral_color: str = "grey", + up_is_good: bool = True, + neutral_color: str = "lightgrey", ) -> str: if delta == 0: return neutral_color @@ -67,8 +67,10 @@ def compute_badge_image( 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) + e.replace("_", "__").replace("-", "--") for e in (label, message, color) if e ) return "https://img.shields.io/badge/" + urllib.parse.quote(f"{code}.svg") diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py index 51956c23..3e023fdf 100644 --- a/coverage_comment/coverage.py +++ b/coverage_comment/coverage.py @@ -3,11 +3,9 @@ import dataclasses import datetime import decimal -import functools -import itertools import json import pathlib -from collections.abc import Iterable, Sequence +from collections.abc import Sequence from coverage_comment import log, subprocess @@ -74,10 +72,6 @@ class FileDiffCoverage: def violation_lines(self) -> list[int]: return self.missing_statements - @functools.cached_property - def violation_lines_collapsed(self): - return list(collapse_lines(self.violation_lines)) - @dataclasses.dataclass class DiffCoverage: @@ -201,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"], @@ -222,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"], @@ -328,11 +318,3 @@ def parse_line_number_diff_line(line: str) -> Sequence[int]: """ start, length = (int(i) for i in (line.split()[2][1:] + ",1").split(",")[:2]) return range(start, start + length) - - -def collapse_lines(lines: list[int]) -> Iterable[tuple[int, int]]: - # All consecutive line numbers have the same difference between their list index and their value. - # Grouping by this difference therefore leads to buckets of consecutive numbers. - for _, it in itertools.groupby(enumerate(lines), lambda x: x[1] - x[0]): - t = list(it) - yield t[0][1], t[-1][1] diff --git a/coverage_comment/diff_grouper.py b/coverage_comment/diff_grouper.py index d48043b4..bc9a54ca 100644 --- a/coverage_comment/diff_grouper.py +++ b/coverage_comment/diff_grouper.py @@ -1,77 +1,17 @@ from __future__ import annotations -import dataclasses -import functools -import itertools -import pathlib from collections.abc import Iterable from coverage_comment import coverage as coverage_module +from coverage_comment import groups MAX_ANNOTATION_GAP = 3 -@dataclasses.dataclass(frozen=True) -class Group: - file: pathlib.Path - line_start: int - line_end: int - - -def compute_contiguous_groups( - values: list[int], separators: set[int], joiners: set[int] -) -> list[tuple[int, int]]: - """ - Given a list of (sorted) values, a list of separators and a list of - joiners, return a list of ranges (start, included end) describing groups of - values. - - 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" - """ - contiguous_groups: list[tuple[int, int]] = [] - for _, contiguous_group in itertools.groupby( - zip(values, itertools.count(1)), lambda x: x[1] - x[0] - ): - grouped_values = (e[0] for e in contiguous_group) - first = next(grouped_values) - try: - *_, last = grouped_values - except ValueError: - last = first - contiguous_groups.append((first, last)) - - def reducer( - acc: list[tuple[int, int]], group: tuple[int, int] - ) -> list[tuple[int, int]]: - if not acc: - return [group] - - last_group = acc[-1] - last_start, last_end = last_group - next_start, next_end = group - - gap = set(range(last_end + 1, next_start)) - joiners - - gap_is_small = len(gap) <= MAX_ANNOTATION_GAP - gap_contains_separators = gap & separators - - if gap_is_small and not gap_contains_separators: - acc[-1] = (last_start, next_end) - return acc - - acc.append(group) - return acc - - return functools.reduce(reducer, contiguous_groups, []) - - def get_diff_missing_groups( coverage: coverage_module.Coverage, diff_coverage: coverage_module.DiffCoverage, -) -> Iterable[Group]: +) -> Iterable[groups.Group]: for path, diff_file in diff_coverage.files.items(): coverage_file = coverage.files[path] @@ -87,12 +27,13 @@ def get_diff_missing_groups( # they are separators. joiners = set(diff_file.added_lines) - separators - for start, end in compute_contiguous_groups( + for start, end in groups.compute_contiguous_groups( values=diff_file.missing_statements, separators=separators, joiners=joiners, + max_gap=MAX_ANNOTATION_GAP, ): - yield Group( + yield groups.Group( file=path, line_start=start, line_end=end, diff --git a/coverage_comment/groups.py b/coverage_comment/groups.py new file mode 100644 index 00000000..7f44f164 --- /dev/null +++ b/coverage_comment/groups.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import dataclasses +import functools +import itertools +import pathlib + + +@dataclasses.dataclass(frozen=True) +class Group: + file: pathlib.Path + line_start: int + line_end: int + + +def compute_contiguous_groups( + 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 + joiners, return a list of ranges (start, included end) describing groups of + values. + + 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_gap values after removing all joiners) + where no line is a "separator" + """ + contiguous_groups: list[tuple[int, int]] = [] + for _, contiguous_group in itertools.groupby( + zip(values, itertools.count(1)), lambda x: x[1] - x[0] + ): + grouped_values = (e[0] for e in contiguous_group) + first = next(grouped_values) + try: + *_, last = grouped_values + except ValueError: + last = first + contiguous_groups.append((first, last)) + + def reducer( + acc: list[tuple[int, int]], group: tuple[int, int] + ) -> list[tuple[int, int]]: + if not acc: + return [group] + + last_group = acc[-1] + last_start, last_end = last_group + next_start, next_end = group + + gap = set(range(last_end + 1, next_start)) - joiners + + gap_is_small = len(gap) <= max_gap + gap_contains_separators = gap & separators + + if gap_is_small and not gap_contains_separators: + acc[-1] = (last_start, next_end) + return acc + + acc.append(group) + return acc + + return functools.reduce(reducer, contiguous_groups, []) diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 5fc6181f..701a1c14 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -156,12 +156,22 @@ def process_pr( ) 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=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, 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 c22e4cac..7a397878 100644 --- a/coverage_comment/template.py +++ b/coverage_comment/template.py @@ -18,7 +18,6 @@ MARKER = ( """""" ) -MAX_FILES = 100 def uptodate(): @@ -26,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 @@ -34,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 @@ -62,33 +67,38 @@ def pluralize(number, singular="", plural="s"): def sign(val: int | decimal.Decimal) -> str: - return "+" if val >= 0 else "" if val < 0 else "±" + 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 val.quantize( - decimal.Decimal("1." + ("0" * precision)), rounding=decimal.ROUND_DOWN - ).normalize() + return remove_exponent( + (decimal.Decimal("100") * val).quantize( + decimal.Decimal("1." + ("0" * precision)), + rounding=decimal.ROUND_DOWN, + ) + ) -def pct(val: decimal.Decimal | float, precision: int = 2) -> str: - if isinstance(val, decimal.Decimal): - val *= decimal.Decimal("100") - rounded = percentage_value(val=val, precision=precision) - return f"{rounded:f}%" - else: - return f"{val:.{precision}%}" +def pct(val: decimal.Decimal, precision: int = 2) -> str: + rounded = percentage_value(val=val, precision=precision) + return f"{rounded:f}%" -def pct_delta(val: decimal.Decimal, precision: int = 2) -> str: - """ - Used for percentage point deltas - """ - return f"({sign(val=val)}{percentage_value(val=val, precision=precision)})" +def x100(val: decimal.Decimal): + return val * 100 @dataclasses.dataclass @@ -113,6 +123,9 @@ def get_comment_markdown( 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, @@ -127,27 +140,19 @@ def get_comment_markdown( env = SandboxedEnvironment(loader=loader) env.filters["pct"] = pct env.filters["delta"] = delta - env.filters["pct_delta"] = pct_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"] = lambda r: badge.get_badge_color( - decimal.Decimal(r) * decimal.Decimal("100"), + env.filters["get_badge_color"] = functools.partial( + badge.get_badge_color, minimum_green=minimum_green, minimum_orange=minimum_orange, ) - env.filters["get_evolution_color"] = badge.get_evolution_badge_color - env.filters["generate_badge"] = badge.get_static_badge_url - env.filters["pluralize"] = pluralize - previous_coverage_files = previous_coverage.files if previous_coverage else {} - - # files contain the MAX_FILES files with the most new missing lines sorted by path - files = select_files( - coverage_files=coverage.files, - diff_coverage_files=diff_coverage.files, - previous_coverage_files=previous_coverage_files, - ) missing_diff_lines = { key: list(value) for key, value in itertools.groupby( @@ -157,13 +162,14 @@ def get_comment_markdown( 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, @@ -180,18 +186,21 @@ def get_comment_markdown( def select_files( - coverage_files: dict[pathlib.Path, coverage_module.FileCoverage], - diff_coverage_files: dict[pathlib.Path, coverage_module.FileDiffCoverage], - previous_coverage_files: dict[pathlib.Path, coverage_module.FileCoverage], - max_files: int = MAX_FILES, -) -> list[FileInfo]: + *, + 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) + 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( @@ -210,8 +219,11 @@ def select_files( if has_diff or has_evolution_from_previous: files.append(file_info) - files = sorted(files, key=lambda x: x.new_missing_lines)[:max_files] - return sorted(files, key=lambda x: x.path) + 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( @@ -267,10 +279,11 @@ def read_template_file(template: str) -> str: def get_file_url( - repo_name: str, - pr_number: int, 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()}" diff --git a/coverage_comment/template_files/comment.md.j2 b/coverage_comment/template_files/comment.md.j2 index 28ca57fe..650ade22 100644 --- a/coverage_comment/template_files/comment.md.j2 +++ b/coverage_comment/template_files/comment.md.j2 @@ -1,85 +1,181 @@ -{%- block title -%}## Coverage report{%- if subproject_id -%} ({{ subproject_id }}){%- endif -%}{%- endblock title -%} +{%- block title -%}## Coverage report{%- if subproject_id %} ({{ subproject_id }}){%- endif -%}{%- endblock title%} {# Coverage evolution badge #} -{%- block coverage_badges -%} +{% 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') -%} -{{ text }} + {%- 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 | get_badge_color -%} -{{ text }} +{%- set color = coverage.info.percent_covered | x100 | get_badge_color -%} + {%- endif -%} {%- endblock coverage_evolution_badge -%} -{# Coverage diff 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" -%} -{{ text }} + {%- 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 -%} +{%- 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 -%} +{%- 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 -%}
Click to see where and how coverage changed - + -{%- for parent, files_in_folder in groupby(attribute="path.parent") -%} +{%- 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.coverage.info.num_statements if file.previous else None), + 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, - statements_count=file.coverage.info.missing_lines, - previous_statements_count=(file.previous.coverage.info.missing_lines if file.previous else None), + 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, - coverage_rate=file.coverage.info.percent_covered, - previous_coverage_rate=(file.previous.coverage.info.percent_covered if file.previous else None), + 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 #} +{#- 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) + 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 #} +{#- Link to missing lines -#} +{%- block link_to_missing_diff_lines_cell scoped -%} +{%- endblock link_to_missing_diff_lines_cell -%} {%- endfor -%} {%- endfor -%} @@ -97,124 +194,71 @@ _This PR does not seem to contain any modification to coverable code._ + {#- 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), + 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", - statements_count=coverage.info.missing_lines, - previous_statements_count=(previous_coverage.info.missing_lines if previous_coverage else None), + 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", - coverage_rate=coverage.info.percent_covered, - previous_coverage_rate=(previous_coverage.info.percent_covered if previous_coverage else None), + 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=(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) + 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 lines)
Lines missing
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  {{ filename }}  {{ parent }}
  {{ diff_file.path.name }}  {{ path.name }} {%- set comma = joiner() -%} {%- for group in missing_diff_lines.get(path, []) -%} - - {{- comma() -}} + + {{- group.line_start -}} {%- if group.line_start != group.line_end -%} - @@ -90,6 +186,7 @@ _This PR does not seem to contain any modification to coverable code._ {%- endfor -%}
Project Total 
-{%- endblock coverage_by_file -%} +{%- if max_files and count_files > max_files %} -This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) -
+> [!NOTE] +> The report is truncated to {{ max_files }} files out of {{ count_files }}. To see the full report, please visit the workflow summary page. -{{ marker -}} - - -{%- macro statements_badge(path, statements_count, previous_statements_count) -%} -{% if previous_statements_count is not None -%} -{% set statements_diff = statements_countprevious_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_countprevious_statements_count ~". The number of new statements is at least " ~ statements_diff ~ " but it may be higher if other statements were simultaneously removed." -%} -{% 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_countprevious_statements_count ~". The number of deleted statements is at least " ~ (-statements_diff) ~ " but it may be higher if other statements were simultaneously added." -%} -{% set color = "49c2ee" -%} -{% else -%} -{% set text = "This PR doesn't change the number of statements in " ~ path ~ ", which is " ~ statements_countprevious_statements_count ~ ". Either the file was mostly unmodified, or the same number of statements was simultenousely added to and removed from the file." -%} -{% set color = "5d89ba" -%} -{% endif -%} -{% set message = statements_diff %} -{% else -%} -{% set text = "This PR adds " ~ statements_countprevious_statements_count ~ (" statements" | pluralize) ~ " to " ~ path ~ ". The file did not seem to exist on the base branch." -%} -{% set color = "007ec6" -%} -{% set message = statements_countprevious_statements_count %} {% endif -%} -{{ text }} -{%- 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 ~ ". This may be the result of adding untested statements and/or removing the tests that were covering existing statements. Also, it's possible that more non-covered statements were added, while other non-covered statements were removed elsewhere in the file." -%} -{%- 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 ~ ". This may be the result of removing untested statements and/or adding tests to cover existing non-covered statements. Also, it's possible that more non-covered statements were removed, while other non-covered statements were added elsewhere in the file." -%} -{%- else -%} -{%- set text = "This PR doesn't change the number of statements missing coverage in " ~ path ~ ", which is " ~ missing_lines_count ~ ". Either the modifications in the file were only to covered lines, or the same number of statements missing coverage was simultenousely added to and removed from the file." -%} {%- endif -%} -{%- set message = missing_diff -%} -{%- else -%} -{%- set text = "This PR adds " ~ missing_lines_count ~ (" statements" | 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) -%} -{{ text }} +{%- endblock coverage_by_file -%} -{%- endmacro -%} +{%- block footer -%} + -{%- macro coverage_rate_badge(path, coverage_rate, previous_coverage_rate) -%} -{%- if previous_coverage_rate is not None -%} -{%- set coverage_diff = coverage_rate - previous_coverage_rate -%} -{%- if coverage_diff > 0 -%} -{%- set text = "This PR adds " ~ (coverage_diff * 100) ~ " percentage points to the coverage rate (number of statements covered by the tests / total number of statements) in " ~ path ~ ", taking it from " ~ previous_coverage_rate | pct ~ " to " ~ coverage_rate | pct ~ "." -%} -{%- elif coverage_diff < 0 -%} -{%- set text = "This PR removes " ~ (-coverage_diff * 100) ~ " percentage points from the coverage rate (number of statements covered by the tests / total number of statements) in " ~ path ~ ", taking it from " ~ previous_coverage_rate | pct ~ " to " ~ coverage_rate | pct ~ "." -%} -{%- else -%} -{%- set text = "This PR doesn't change the coverage rate (number of statements covered by the tests / total number of statements) in " ~ path ~ ", which is " ~ coverage_rate | pct ~ "." -%} -{%- endif -%} -{%- set color = coverage_diff | get_evolution_color(up_is_good=False) -%} -{%- set message = (coverage_diff | pct_delta(precision=0)) -%} -{%- else -%} -{%- set text = "The coverage rate (number of statements covered by the tests / total number of statements) of " ~ path ~ " is " ~ coverage_rate | pct ~ ". The file did not seem to exist on the base branch." -%} -{%- set message = "-" -%} -{%- set color = "grey" -%} -{%- endif -%} -{{ text }} +This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action) -{%- endmacro -%} + -{%- macro diff_coverage_rate_badge(path, added_statements_count, covered_statements_count, percent_covered) -%} -{% if added_statements_count -%} -{% set text = "This PR adds " ~ (added_statements_count) ~ " statements to " ~ path ~ ", " ~ (percent_covered) ~ " of which are covered, the coverage rate on the diff is " ~ percent_covered | pct ~ "." -%} -{% set label = (percent_covered | pct(precision=0)) -%} -{% set message = "(" ~ percent_covered ~ "/" ~ added_statements_count ~ ")" -%} -{%- set color = (percent_covered | get_badge_color()) -%} -{% else -%} -{% set text = "This PR does not seem to add statements to " ~ path ~ "." -%} -{% set label = "-" -%} -{%- set color = "grey" -%} -{% set message = "-" -%} -{% endif -%} -{{ text }} +{%- endblock footer -%} + -{%- endmacro -%} +{{ 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 978ddc30..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 diff --git a/tests/conftest.py b/tests/conftest.py index e7700c96..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_statements=[3, 7, 8, 9, 12], - added_lines=[3, 4, 5, 6, 7, 8, 9, 12], - ) - }, - ) - - -@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_statements=[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_statements=[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/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 72fd079b..a2419e90 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -192,10 +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 for the whole project went from unknown to 77.77%" in comment - assert "75% of the code lines added by this PR are covered" in comment assert ( - "### [foo.py](https://github.com/py-cov-action/foobar/pull/2/files#diff-b08fd7a517303ab07cfa211f74d03c1a4c2e64b3b0656d84ff32ecb449b785d2)\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 ( @@ -269,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,8 +333,7 @@ def checker(payload): assert not pathlib.Path("python-coverage-comment-action.txt").exists() 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(): diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py index 53fe6000..1a1ad8d3 100644 --- a/tests/unit/test_coverage.py +++ b/tests/unit/test_coverage.py @@ -9,12 +9,6 @@ from coverage_comment import coverage, subprocess -def test_collapse_lines(): - assert list( - coverage.collapse_lines([1, 2, 3, 5, 6, 8, 11, 12, 17, 18, 19, 20, 21, 99]) - ) == [(1, 3), (5, 6), (8, 8), (11, 12), (17, 21), (99, 99)] - - @pytest.mark.parametrize( "num_covered, num_total, expected_coverage", [ @@ -143,6 +137,8 @@ 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"), + added_statements=[1, 3], + covered_statements=[1], missing_statements=[3], added_lines=[1, 3], ) @@ -180,6 +176,8 @@ def test_generate_coverage_markdown(mocker): pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/code.py"), percent_covered=decimal.Decimal("1"), + added_statements=[], + covered_statements=[], missing_statements=[], added_lines=[4, 5, 6], ) @@ -213,12 +211,16 @@ def test_generate_coverage_markdown(mocker): pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage( path=pathlib.Path("codebase/code.py"), percent_covered=decimal.Decimal("1"), + 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"), + 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 index 23d6f296..e20a647f 100644 --- a/tests/unit/test_diff_grouper.py +++ b/tests/unit/test_diff_grouper.py @@ -62,20 +62,24 @@ def test_group_annotations(coverage_obj, diff_coverage_obj): assert list(result) == [ diff_grouper.Group( - file=pathlib.Path("codebase/code.py"), line_start=7, line_end=9 - ) + file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8 + ), ] def test_group_annotations_more_files( - coverage_obj, diff_coverage_obj_many_missing_lines + coverage_obj_more_files, diff_coverage_obj_more_files ): result = diff_grouper.get_diff_missing_groups( - coverage=coverage_obj, diff_coverage=diff_coverage_obj_many_missing_lines + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, ) assert list(result) == [ diff_grouper.Group( - file=pathlib.Path("codebase/code.py"), line_start=7, line_end=9 - ) + file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8 + ), + diff_grouper.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..64582619 100644 --- a/tests/unit/test_files.py +++ b/tests/unit/test_files.py @@ -65,8 +65,9 @@ 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"), ) diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py index 0aed8149..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,10 +9,20 @@ 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"), @@ -35,24 +44,28 @@ 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"), @@ -62,107 +75,118 @@ def test_template(coverage_obj, diff_coverage_obj): {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %} """, ) + print(result) expected = """## Coverage report (foo) - -Coverage for the whole project went from 92% to 75% -80% of the code lines added by this PR are covered -Branch coverage for the whole project on this PR is 50% - -
-Diff Coverage details (click to unfold) -### [codebase/code.py](https://github.com/org/repo/pull/5/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3) -`80%` of new lines are covered (`75%` of the complete file). -Missing lines: [`3`](https://github.com/org/repo/pull/5/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R3-R3), [`7-9`](https://github.com/org/repo/pull/5/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R7-R9), [`12`](https://github.com/org/repo/pull/5/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R12-R12) +
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) + +
-
""" 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_statements=[12, 13, 14, 22], - added_lines=[12, 13, 14, 15, 16, 22], - ), - pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage( - path=pathlib.Path("codebase/other.py"), - percent_covered=decimal.Decimal("1"), - missing_statements=[], - 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="", @@ -171,93 +195,193 @@ def test_template_full(): base_template=template.read_template_file("comment.md.j2"), ) expected = """## Coverage report - -Coverage for the whole project went from 100% to 100% -100% of the code lines added by this PR are covered -Branch coverage for the whole project on this PR is 100% - -
-Diff Coverage details (click to unfold) -### [codebase/code.py](https://github.com/org/repo/pull/12/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3) -`50%` of new lines are covered (`83.33%` of the complete file). -Missing lines: [`12-14`](https://github.com/org/repo/pull/12/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R12-R14), [`22`](https://github.com/org/repo/pull/12/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R22-R22) +
Click to see where and how coverage changed + + + + + + + + + + + -### [codebase/other.py](https://github.com/org/repo/pull/12/files#diff-30cad827f61772ec66bb9ef8887058e6d8443a2afedb331d800feaa60228a26e) -`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=1, + pr_number=3, base_template=template.read_template_file("comment.md.j2"), ) expected = """## Coverage report - -Coverage for the whole project went from 100% to 75% -100% of the code lines added by this PR are covered -Branch coverage for the whole project on this PR is 50% - +
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, - minimum_green=decimal.Decimal("100"), - minimum_orange=decimal.Decimal("70"), + 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 %} + """, + ) + print(result) + + assert "The report is truncated to 1 files out of 2." in result + + +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=3, + 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 %} + """, ) - expected = """## Coverage report - -Coverage for the whole project went from unknown to 75% -80% of the code lines added by this PR are covered - + print(result) -
-Diff Coverage details (click to unfold) + assert "The report is truncated" not in result + assert "code.py" in result + assert "other.py" in result -### [codebase/code.py](https://github.com/org/repo/pull/3/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3) -`80%` of new lines are covered (`75%` of the complete file). -Missing lines: [`3`](https://github.com/org/repo/pull/3/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R3-R3), [`7-9`](https://github.com/org/repo/pull/3/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R7-R9), [`12`](https://github.com/org/repo/pull/3/files#diff-c05d5557f0c1ff3761df2f49e3b541cfc161f4f0d63e2a66d568f090065bc3d3R12-R12) -Missing lines: `3`, `7-9`, `12` -
-""" - 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 %}" ) @@ -265,6 +389,10 @@ 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"), @@ -281,7 +409,11 @@ 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"), @@ -296,20 +428,37 @@ 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, result", + "filepath, lines, expected", [ ( pathlib.Path("tests/conftest.py"), @@ -322,17 +471,20 @@ def test_pct(value, displayed_coverage): "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-b10564ab7d2c520cdd0243874879fb0a782862c3c902ab535faabe57d5a505e1R12-R15", ), ( - pathlib.Path("codebase/main.py"), + pathlib.Path("codebase/other.py"), (22, 22), - "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-78013e21ec15af196dec6bfa8fd19ba3f6be7d390545d0cff142e47d803316faR22-R22", + "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-30cad827f61772ec66bb9ef8887058e6d8443a2afedb331d800feaa60228a26eR22-R22", ), ], ) -def test_get_file_url_function(filepath, lines, result): - file_url = template.get_file_url_function( - "py-cov-action/python-coverage-comment-action", 33 +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 file_url(pathlib.Path(filepath), lines) == result + assert result == expected def test_uptodate(): @@ -351,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:")