Repository: py-cov-action/python-coverage-comment-action (python)

Issue:
Use decimals/fractions everywhere
Floating-point types make knowing whether the coverage increased, decreased or stayed the same quite complicated (mainly: when it stays the same, floating point errors can make us think it changed).

There's [a bit of work](https://github.com/ewjoachim/python-coverage-comment-action/pull/33/commits/08619a78de77bb96cd4609de2650ec3081fb9c4b#diff-61bf637d10e8406acddf352767e17c4c775522b38e64c5f0e065750bcc1d8014R27-R35) in #33 but I guess we'd gain from using consistently fractions or decimals everywhere applicable.

Pull request:
Use `decimal.Decimal` instead of `float`
Closes #37

This PR replaces all previous usages of `float` with `decimal.Decimal` to prevent floating-point errors.
I've tried to change all the things adequately, but it's still quite possible that I missed a spot that actually needs a change.
This also allows us to present coverage data more accurately:
![1](https://user-images.githubusercontent.com/81773954/205161498-43dcdd3f-ff3d-48b5-8491-d579f147423c.png)


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9fca894..ffd3dc2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -33,7 +33,7 @@ repos:
   - id: flake8
 
 - repo: https://github.com/asottile/pyupgrade
-  rev: "v3.3.0"
+  rev: "v3.2.2"
   hooks:
   - id: pyupgrade
     args: ["--py310-plus"]
diff --git a/coverage_comment/badge.py b/coverage_comment/badge.py
index 8d64c91..9c7fe6c 100644
--- a/coverage_comment/badge.py
+++ b/coverage_comment/badge.py
@@ -3,6 +3,7 @@ This module should contain only the things relevant to the badge being computed
 by shields.io
 """
 
+import decimal
 import json
 import urllib.parse
 
@@ -10,10 +11,9 @@ import httpx
 
 
 def get_badge_color(
-    # All float values are between 0 and 100 with 2 decimal places
-    rate: float,
-    minimum_green: float,
-    minimum_orange: float,
+    rate: decimal.Decimal,
+    minimum_green: decimal.Decimal,
+    minimum_orange: decimal.Decimal,
 ) -> str:
     if rate >= minimum_green:
         return "brightgreen"
@@ -24,10 +24,9 @@ def get_badge_color(
 
 
 def compute_badge_endpoint_data(
-    line_rate: float,
+    line_rate: decimal.Decimal,
     color: str,
 ) -> str:
-
     badge = {
         "schemaVersion": 1,
         "label": "Coverage",
@@ -39,7 +38,7 @@ def compute_badge_endpoint_data(
 
 
 def compute_badge_image(
-    line_rate: float, color: str, http_session: httpx.Client
+    line_rate: decimal.Decimal, color: str, http_session: httpx.Client
 ) -> str:
     return http_session.get(
         "https://img.shields.io/static/v1?"
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index bf2ce60..e9e259e 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -1,5 +1,6 @@
 import dataclasses
 import datetime
+import decimal
 import json
 import pathlib
 import tempfile
@@ -19,7 +20,7 @@ class CoverageMetadata:
 class CoverageInfo:
     covered_lines: int
     num_statements: int
-    percent_covered: float
+    percent_covered: decimal.Decimal
     missing_lines: int
     excluded_lines: int
     num_branches: int | None
@@ -47,7 +48,7 @@ class Coverage:
 @dataclasses.dataclass
 class FileDiffCoverage:
     path: str
-    percent_covered: float
+    percent_covered: decimal.Decimal
     violation_lines: list[int]
 
 
@@ -55,11 +56,17 @@ class FileDiffCoverage:
 class DiffCoverage:
     total_num_lines: int
     total_num_violations: int
-    total_percent_covered: float
+    total_percent_covered: decimal.Decimal
     num_changed_lines: int
     files: dict[pathlib.Path, FileDiffCoverage]
 
 
+def compute_coverage(num_covered: int, num_total: int) -> decimal.Decimal:
+    if num_total == 0:
+        return decimal.Decimal("1")
+    return decimal.Decimal(num_covered) / decimal.Decimal(num_total)
+
+
 def get_coverage_info(merge: bool) -> Coverage:
     try:
         if merge:
@@ -139,7 +146,12 @@ def extract_info(data) -> Coverage:
                 info=CoverageInfo(
                     covered_lines=file_data["summary"]["covered_lines"],
                     num_statements=file_data["summary"]["num_statements"],
-                    percent_covered=file_data["summary"]["percent_covered"] / 100,
+                    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),
+                    ),
                     missing_lines=file_data["summary"]["missing_lines"],
                     excluded_lines=file_data["summary"]["excluded_lines"],
                     num_branches=file_data["summary"].get("num_branches"),
@@ -155,7 +167,12 @@ def extract_info(data) -> Coverage:
         info=CoverageInfo(
             covered_lines=data["totals"]["covered_lines"],
             num_statements=data["totals"]["num_statements"],
-            percent_covered=data["totals"]["percent_covered"] / 100,
+            percent_covered=compute_coverage(
+                data["totals"]["covered_lines"]
+                + data["totals"].get("covered_branches", 0),
+                data["totals"]["num_statements"]
+                + data["totals"].get("num_branches", 0),
+            ),
             missing_lines=data["totals"]["missing_lines"],
             excluded_lines=data["totals"]["excluded_lines"],
             num_branches=data["totals"].get("num_branches"),
@@ -204,12 +221,16 @@ def extract_diff_info(data) -> DiffCoverage:
     return DiffCoverage(
         total_num_lines=data["total_num_lines"],
         total_num_violations=data["total_num_violations"],
-        total_percent_covered=data["total_percent_covered"] / 100,
+        total_percent_covered=compute_coverage(
+            data["total_num_lines"] - data["total_num_violations"],
+            data["total_num_lines"],
+        ),
         num_changed_lines=data["num_changed_lines"],
         files={
             path: FileDiffCoverage(
                 path=path,
-                percent_covered=file_data["percent_covered"] / 100,
+                percent_covered=decimal.Decimal(str(file_data["percent_covered"]))
+                / decimal.Decimal("100"),
                 violation_lines=file_data["violation_lines"],
             )
             for path, file_data in data["src_stats"].items()
diff --git a/coverage_comment/default.md.j2 b/coverage_comment/default.md.j2
index 42e5497..9554314 100644
--- a/coverage_comment/default.md.j2
+++ b/coverage_comment/default.md.j2
@@ -5,9 +5,9 @@
 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 < coverage.info.percent_covered -%}
+{%- if previous_coverage_rate | float < coverage.info.percent_covered | float -%}
 {%- block emoji_coverage_up -%}:arrow_up:{%- endblock emoji_coverage_up -%}
-{%- elif previous_coverage_rate > coverage.info.percent_covered -%}
+{%- elif previous_coverage_rate | float > coverage.info.percent_covered | float -%}
 {%- block emoji_coverage_down -%}:arrow_down:{%- endblock emoji_coverage_down -%}
 {%- else -%}
 {%- block emoji_coverage_constant -%}:arrow_right:{%- endblock emoji_coverage_constant -%}
diff --git a/coverage_comment/files.py b/coverage_comment/files.py
index 3f2b7da..ef321f4 100644
--- a/coverage_comment/files.py
+++ b/coverage_comment/files.py
@@ -24,24 +24,13 @@ class FileWithPath:
     contents: str
 
 
-def get_percentage(line_rate: float) -> float:
-    """
-    From a number between 0 and 1, compute a number between 0 and 100
-    truncated to 2 decimal digits.
-    """
-    # This was surprising hard to get right. Simple implementations
-    # kept rounding `5.1` to `5.09`.
-    dec = decimal.Decimal(str(line_rate)) * 100
-    return float(dec.quantize(decimal.Decimal("0.01"), rounding=decimal.ROUND_DOWN))
-
-
 def compute_files(
-    line_rate: float,
-    minimum_green: float,
-    minimum_orange: float,
+    line_rate: decimal.Decimal,
+    minimum_green: decimal.Decimal,
+    minimum_orange: decimal.Decimal,
     http_session: httpx.Client,
 ) -> list[FileWithPath]:
-    line_rate = get_percentage(line_rate)
+    line_rate *= decimal.Decimal("100")
     color = badge.get_badge_color(
         rate=line_rate,
         minimum_green=minimum_green,
@@ -67,12 +56,14 @@ def compute_files(
     ]
 
 
-def compute_datafile(line_rate: float) -> str:
-    return json.dumps({"coverage": line_rate})
+def compute_datafile(line_rate: decimal.Decimal) -> str:
+    return json.dumps({"coverage": float(line_rate)})
 
 
-def parse_datafile(contents):
-    return float(json.loads(contents)["coverage"]) / 100
+def parse_datafile(contents) -> decimal.Decimal:
+    return decimal.Decimal(str(json.loads(contents)["coverage"])) / decimal.Decimal(
+        "100"
+    )
 
 
 class ImageURLs(TypedDict):
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index 2057335..6355032 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -1,4 +1,5 @@
 import dataclasses
+import decimal
 import inspect
 import pathlib
 from typing import Any
@@ -40,8 +41,8 @@ class Config:
     COMMENT_ARTIFACT_NAME: str = "python-coverage-comment-action"
     COMMENT_FILENAME: pathlib.Path = pathlib.Path("python-coverage-comment-action.txt")
     GITHUB_OUTPUT: pathlib.Path | None = None
-    MINIMUM_GREEN: float = 100.0
-    MINIMUM_ORANGE: float = 70.0
+    MINIMUM_GREEN: decimal.Decimal = decimal.Decimal("100")
+    MINIMUM_ORANGE: decimal.Decimal = decimal.Decimal("70")
     MERGE_COVERAGE_FILES: bool = False
     ANNOTATE_MISSING_LINES: bool = False
     ANNOTATION_TYPE: str = "warning"
@@ -51,12 +52,12 @@ class Config:
 
     # Clean methods
     @classmethod
-    def clean_minimum_green(cls, value: str) -> float:
-        return float(value)
+    def clean_minimum_green(cls, value: str) -> decimal.Decimal:
+        return decimal.Decimal(value)
 
     @classmethod
-    def clean_minimum_orange(cls, value: str) -> float:
-        return float(value)
+    def clean_minimum_orange(cls, value: str) -> decimal.Decimal:
+        return decimal.Decimal(value)
 
     @classmethod
     def clean_github_pr_run_id(cls, value: str) -> int | None:
diff --git a/coverage_comment/template.py b/coverage_comment/template.py
index 4f50866..5001902 100644
--- a/coverage_comment/template.py
+++ b/coverage_comment/template.py
@@ -1,3 +1,4 @@
+import decimal
 from collections.abc import Callable
 from importlib import resources
 
@@ -41,7 +42,7 @@ class TemplateError(Exception):
 def get_markdown_comment(
     coverage: coverage_module.Coverage,
     diff_coverage: coverage_module.DiffCoverage,
-    previous_coverage_rate: float | None,
+    previous_coverage_rate: decimal.Decimal | None,
     base_template: str,
     custom_template: str | None = None,
 ):
@@ -69,5 +70,9 @@ def read_template_file() -> str:
     return resources.read_text("coverage_comment", "default.md.j2")
 
 
-def pct(val):
-    return f"{val:.0%}"
+def pct(val: decimal.Decimal | float) -> str:
+    if type(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%}"


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e24896c..411c38c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
 import datetime
+import decimal
 import functools
 import io
 import os
@@ -142,7 +143,7 @@ def coverage_obj():
         info=coverage_module.CoverageInfo(
             covered_lines=5,
             num_statements=6,
-            percent_covered=0.75,
+            percent_covered=decimal.Decimal("0.75"),
             missing_lines=1,
             excluded_lines=0,
             num_branches=2,
@@ -159,7 +160,7 @@ def coverage_obj():
                 info=coverage_module.CoverageInfo(
                     covered_lines=5,
                     num_statements=6,
-                    percent_covered=0.75,
+                    percent_covered=decimal.Decimal("0.75"),
                     missing_lines=1,
                     excluded_lines=0,
                     num_branches=2,
@@ -184,7 +185,7 @@ def coverage_obj_no_branch():
         info=coverage_module.CoverageInfo(
             covered_lines=5,
             num_statements=6,
-            percent_covered=0.75,
+            percent_covered=decimal.Decimal("0.75"),
             missing_lines=1,
             excluded_lines=0,
             num_branches=None,
@@ -201,7 +202,7 @@ def coverage_obj_no_branch():
                 info=coverage_module.CoverageInfo(
                     covered_lines=5,
                     num_statements=6,
-                    percent_covered=0.75,
+                    percent_covered=decimal.Decimal("0.75"),
                     missing_lines=1,
                     excluded_lines=0,
                     num_branches=None,
@@ -226,7 +227,7 @@ def coverage_obj_many_missing_lines():
         info=coverage_module.CoverageInfo(
             covered_lines=7,
             num_statements=10,
-            percent_covered=0.8,
+            percent_covered=decimal.Decimal("0.8"),
             missing_lines=12,
             excluded_lines=0,
             num_branches=2,
@@ -243,7 +244,7 @@ def coverage_obj_many_missing_lines():
                 info=coverage_module.CoverageInfo(
                     covered_lines=5,
                     num_statements=10,
-                    percent_covered=0.5,
+                    percent_covered=decimal.Decimal("0.5"),
                     missing_lines=5,
                     excluded_lines=0,
                     num_branches=2,
@@ -260,7 +261,7 @@ def coverage_obj_many_missing_lines():
                 info=coverage_module.CoverageInfo(
                     covered_lines=3,
                     num_statements=6,
-                    percent_covered=0.5,
+                    percent_covered=decimal.Decimal("0.5"),
                     missing_lines=3,
                     excluded_lines=0,
                     num_branches=2,
@@ -278,12 +279,12 @@ def diff_coverage_obj():
     return coverage_module.DiffCoverage(
         total_num_lines=5,
         total_num_violations=1,
-        total_percent_covered=0.8,
+        total_percent_covered=decimal.Decimal("0.8"),
         num_changed_lines=39,
         files={
             "codebase/code.py": coverage_module.FileDiffCoverage(
                 path="codebase/code.py",
-                percent_covered=0.8,
+                percent_covered=decimal.Decimal("0.8"),
                 violation_lines=[7, 9],
             )
         },
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index f6b4b0f..c43fddc 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -125,10 +125,10 @@ def test_action__pull_request__store_comment(
     comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text()
     assert comment == comment_file
     assert "No coverage data of the default branch was found for comparison" in comment
-    assert "The coverage rate is `86%`" in comment
+    assert "The coverage rate is `85.71%`" in comment
     assert "`100%` of new lines are covered." in comment
     assert (
-        "### foo.py\n`100%` of new lines are covered (`86%` of the complete file)"
+        "### foo.py\n`100%` of new lines are covered (`85.71%` of the complete file)"
         in comment
     )
     assert (
@@ -183,7 +183,7 @@ def test_action__pull_request__post_comment(
     assert result == 0
 
     assert not pathlib.Path("python-coverage-comment-action.txt").exists()
-    assert "The coverage rate went from `30%` to `86%` :arrow_up:" in comment
+    assert "The coverage rate went from `30%` to `85.71%` :arrow_up:" in comment
 
     expected_output = "COMMENT_FILE_WRITTEN=false\n"
 
diff --git a/tests/unit/test_badge.py b/tests/unit/test_badge.py
index ccdd87a..83460ca 100644
--- a/tests/unit/test_badge.py
+++ b/tests/unit/test_badge.py
@@ -1,3 +1,5 @@
+import decimal
+
 import pytest
 
 from coverage_comment import badge
@@ -6,19 +8,24 @@ from coverage_comment import badge
 @pytest.mark.parametrize(
     "rate, expected",
     [
-        (10, "red"),
-        (80, "orange"),
-        (99, "brightgreen"),
+        (decimal.Decimal("10"), "red"),
+        (decimal.Decimal("80"), "orange"),
+        (decimal.Decimal("99"), "brightgreen"),
     ],
 )
 def test_get_badge_color(rate, expected):
-    color = badge.get_badge_color(rate=rate, minimum_green=90, minimum_orange=60)
+    color = badge.get_badge_color(
+        rate=rate,
+        minimum_green=decimal.Decimal("90"),
+        minimum_orange=decimal.Decimal("60"),
+    )
     assert color == expected
 
 
 def test_compute_badge_endpoint_data():
-
-    badge_data = badge.compute_badge_endpoint_data(line_rate=27.42, color="red")
+    badge_data = badge.compute_badge_endpoint_data(
+        line_rate=decimal.Decimal("27.42"), color="red"
+    )
     expected = """{"schemaVersion": 1, "label": "Coverage", "message": "27%", "color": "red"}"""
     assert badge_data == expected
 
@@ -29,7 +36,7 @@ def test_compute_badge_image(session):
     )(text="foo")
 
     badge_data = badge.compute_badge_image(
-        line_rate=27.42, color="red", http_session=session
+        line_rate=decimal.Decimal("27.42"), color="red", http_session=session
     )
 
     assert badge_data == "foo"
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index 2df76e5..5c6e1f7 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -1,3 +1,4 @@
+import decimal
 import json
 
 import pytest
@@ -5,6 +6,22 @@ import pytest
 from coverage_comment import coverage, subprocess
 
 
+@pytest.mark.parametrize(
+    "num_covered, num_total, expected_coverage",
+    [
+        (0, 10, "0"),
+        (0, 0, "1"),
+        (5, 0, "1"),
+        (5, 10, "0.5"),
+        (1, 100, "0.01"),
+    ],
+)
+def test_compute_coverage(num_covered, num_total, expected_coverage):
+    assert coverage.compute_coverage(num_covered, num_total) == decimal.Decimal(
+        expected_coverage
+    )
+
+
 def test_get_coverage_info(mocker, coverage_json, coverage_obj):
     run = mocker.patch(
         "coverage_comment.subprocess.run", return_value=json.dumps(coverage_json)
diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py
index 27d52d5..152314d 100644
--- a/tests/unit/test_files.py
+++ b/tests/unit/test_files.py
@@ -1,40 +1,18 @@
+import decimal
 import pathlib
 
-import pytest
-
 from coverage_comment import files
 
 
-@pytest.mark.parametrize(
-    "input, output",
-    [
-        (1, 100),
-        (0.99999, 99.99),
-        (0.05, 5.0),
-        (0.051, 5.1),
-        (0.0512, 5.12),
-        (0.05121, 5.12),
-        (0.05129, 5.12),
-    ],
-)
-def test_get_percentage(input, output):
-    # Yeah, I didn't foresee that we would need so many samples to make
-    # sure the function works, but floats are hard. It all comes from:
-    # >>> 5.1 * 100
-    # 509.99999999999994
-    assert files.get_percentage(input) == output
-
-
 def test_compute_files(session):
-
     session.register(
         "GET", "https://img.shields.io/static/v1?label=Coverage&message=12%25&color=red"
     )(text="foo")
 
     result = files.compute_files(
-        line_rate=0.1234,
-        minimum_green=25,
-        minimum_orange=70,
+        line_rate=decimal.Decimal("0.1234"),
+        minimum_green=decimal.Decimal("25"),
+        minimum_orange=decimal.Decimal("70"),
         http_session=session,
     )
     expected = [
@@ -51,11 +29,16 @@ def test_compute_files(session):
 
 
 def test_compute_datafile():
-    assert files.compute_datafile(line_rate=12.34) == """{"coverage": 12.34}"""
+    assert (
+        files.compute_datafile(line_rate=decimal.Decimal("12.34"))
+        == """{"coverage": 12.34}"""
+    )
 
 
 def test_parse_datafile():
-    assert files.parse_datafile(contents="""{"coverage": 12.34}""") == 0.1234
+    assert files.parse_datafile(contents="""{"coverage": 12.34}""") == decimal.Decimal(
+        "0.1234"
+    )
 
 
 def test_get_urls():
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index 6bd9526..4dbaeb5 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -1,3 +1,4 @@
+import decimal
 import pathlib
 
 import pytest
@@ -55,8 +56,8 @@ def test_config__from_environ__ok():
         COMMENT_FILENAME=pathlib.Path("qux"),
         COMMENT_TEMPLATE="footemplate",
         COVERAGE_DATA_BRANCH="branchname",
-        MINIMUM_GREEN=90.0,
-        MINIMUM_ORANGE=50.8,
+        MINIMUM_GREEN=decimal.Decimal("90"),
+        MINIMUM_ORANGE=decimal.Decimal("50.8"),
         MERGE_COVERAGE_FILES=True,
         ANNOTATE_MISSING_LINES=False,
         ANNOTATION_TYPE="error",
@@ -77,8 +78,8 @@ def config():
         "COMMENT_ARTIFACT_NAME": "baz",
         "COMMENT_FILENAME": pathlib.Path("qux"),
         "COVERAGE_DATA_BRANCH": "branchname",
-        "MINIMUM_GREEN": 90.0,
-        "MINIMUM_ORANGE": 50.8,
+        "MINIMUM_GREEN": decimal.Decimal("90"),
+        "MINIMUM_ORANGE": decimal.Decimal("50.8"),
         "MERGE_COVERAGE_FILES": True,
         "VERBOSE": False,
     }
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index d914091..2629fee 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -1,4 +1,5 @@
 import datetime
+import decimal
 
 import pytest
 
@@ -10,7 +11,7 @@ def test_get_markdown_comment(coverage_obj, diff_coverage_obj):
         template.get_markdown_comment(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
-            previous_coverage_rate=0.92,
+            previous_coverage_rate=decimal.Decimal("0.92"),
             base_template="""
         {{ previous_coverage_rate | pct }}
         {{ coverage.info.percent_covered | pct }}
@@ -41,7 +42,7 @@ def test_template(coverage_obj, diff_coverage_obj):
     result = template.get_markdown_comment(
         coverage=coverage_obj,
         diff_coverage=diff_coverage_obj,
-        previous_coverage_rate=0.92,
+        previous_coverage_rate=decimal.Decimal("0.92"),
         base_template=template.read_template_file(),
         custom_template="""{% extends "base" %}
         {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
@@ -77,7 +78,7 @@ def test_template_full():
         info=coverage.CoverageInfo(
             covered_lines=6,
             num_statements=6,
-            percent_covered=1.0,
+            percent_covered=decimal.Decimal("1"),
             missing_lines=0,
             excluded_lines=0,
             num_branches=2,
@@ -94,7 +95,7 @@ def test_template_full():
                 info=coverage.CoverageInfo(
                     covered_lines=5,
                     num_statements=6,
-                    percent_covered=5 / 6,
+                    percent_covered=decimal.Decimal("5") / decimal.Decimal("6"),
                     missing_lines=1,
                     excluded_lines=0,
                     num_branches=2,
@@ -111,7 +112,7 @@ def test_template_full():
                 info=coverage.CoverageInfo(
                     covered_lines=6,
                     num_statements=6,
-                    percent_covered=1.0,
+                    percent_covered=decimal.Decimal("1"),
                     missing_lines=0,
                     excluded_lines=0,
                     num_branches=2,
@@ -126,17 +127,17 @@ def test_template_full():
     diff_cov = coverage.DiffCoverage(
         total_num_lines=6,
         total_num_violations=0,
-        total_percent_covered=1.0,
+        total_percent_covered=decimal.Decimal("1"),
         num_changed_lines=39,
         files={
             "codebase/code.py": coverage.FileDiffCoverage(
                 path="codebase/code.py",
-                percent_covered=1 / 2,
+                percent_covered=decimal.Decimal("0.5"),
                 violation_lines=[5],
             ),
             "codebase/other.py": coverage.FileDiffCoverage(
                 path="codebase/other.py",
-                percent_covered=1,
+                percent_covered=decimal.Decimal("1"),
                 violation_lines=[],
             ),
         },
@@ -145,7 +146,7 @@ def test_template_full():
     result = template.get_markdown_comment(
         coverage=cov,
         diff_coverage=diff_cov,
-        previous_coverage_rate=1.0,
+        previous_coverage_rate=decimal.Decimal("1.0"),
         base_template=template.read_template_file(),
     )
     expected = """## Coverage report
@@ -158,7 +159,7 @@ The branch rate is `100%`.
 <summary>Diff Coverage details (click to unfold)</summary>
 
 ### codebase/code.py
-`50%` of new lines are covered (`83%` of the complete file).
+`50%` of new lines are covered (`83.33%` of the complete file).
 Missing lines: `5`
 
 ### codebase/other.py
@@ -173,7 +174,7 @@ 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=1.0,
+        total_percent_covered=decimal.Decimal("1"),
         num_changed_lines=39,
         files={},
     )
@@ -181,7 +182,7 @@ def test_template__no_new_lines_with_coverage(coverage_obj):
     result = template.get_markdown_comment(
         coverage=coverage_obj,
         diff_coverage=diff_cov,
-        previous_coverage_rate=1.0,
+        previous_coverage_rate=decimal.Decimal("1.0"),
         base_template=template.read_template_file(),
     )
     expected = """## Coverage report
@@ -234,7 +235,7 @@ def test_template__no_marker(coverage_obj, diff_coverage_obj):
         template.get_markdown_comment(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
-            previous_coverage_rate=0.92,
+            previous_coverage_rate=decimal.Decimal("0.92"),
             base_template=template.read_template_file(),
             custom_template="""foo bar""",
         )
@@ -246,14 +247,25 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj):
         template.get_markdown_comment(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
-            previous_coverage_rate=0.92,
+            previous_coverage_rate=decimal.Decimal("0.92"),
             base_template=template.read_template_file(),
             custom_template="""{% extends "foo" %}""",
         )
 
 
-def test_pct():
-    assert template.pct(0.83) == "83%"
+@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%"),
+    ],
+)
+def test_pct(value, displayed_coverage):
+    assert template.pct(decimal.Decimal(value)) == displayed_coverage
 
 
 def test_uptodate():