Skip to content

Commit ae02954

Browse files
authored
test: reach 100% line and branch coverage and gate it in CI (#342)
1 parent d3f2d6f commit ae02954

12 files changed

Lines changed: 477 additions & 7 deletions

‎.github/workflows/test.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ jobs:
5555
uv pip install --editable .
5656
5757
- name: Run pytest
58-
run: uv run python -m pytest tests/ -v --tb=short
58+
# Coverage must stay at 100% (pyproject.toml), except on 3.15: PyYAML
59+
# has no wheel for it yet, so the gallery scripts' tests are skipped there
60+
run: >-
61+
uv run python -m pytest tests/ -v --tb=short --cov --cov-report=term-missing
62+
${{ matrix.python-version == '3.15' && '--cov-fail-under=0' || '' }}
5963
6064
- name: Generate GitStats Report (smoke test)
6165
if: ${{ matrix.python-version == '3.10' }}

‎gitstats/main.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@ def _collect_commit_subjects(self) -> None:
11221122
except ValueError:
11231123
continue
11241124
subject = line[pos + 1 :].strip()
1125-
if subject:
1125+
if subject: # pragma: no branch - a stripped line has text after its space
11261126
by_year.setdefault(year, []).append(subject[:100])
11271127

11281128
self.commit_subjects_by_year = {
@@ -1154,7 +1154,7 @@ def refine(self) -> None:
11541154
# Compute new contributors per month from author first-commit stamps
11551155
for name in self.authors:
11561156
a = self.authors[name]
1157-
if "first_commit_stamp" in a:
1157+
if "first_commit_stamp" in a: # pragma: no branch - every author has one by now
11581158
first_month = datetime.datetime.fromtimestamp(a["first_commit_stamp"]).strftime(
11591159
"%Y-%m"
11601160
)

‎gitstats/utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def format_duration(delta: datetime.timedelta) -> str:
7474

7575
def format_bytes(size: float) -> str:
7676
"""Render a byte count in binary units (161307.78 -> "157.5 KB")."""
77-
for unit in ("bytes", "KB", "MB", "GB"):
77+
for unit in ("bytes", "KB", "MB", "GB"): # pragma: no branch - breaks at GB at the latest
7878
if abs(size) < 1024 or unit == "GB":
7979
break
8080
size /= 1024

‎pyproject.toml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ documentation = "https://gitstats.readthedocs.io/"
4747
[project.optional-dependencies]
4848
dev = ["nox"]
4949
# PyYAML: the gallery scripts' tests, where it has a wheel (not yet on 3.15)
50-
test = ["pytest>=7.0", "pyyaml>=6.0; python_version < '3.15'"]
50+
test = ["pytest>=7.0", "pytest-cov", "pyyaml>=6.0; python_version < '3.15'"]
5151
docs = ["sphinx", "sphinx-rtd-theme", "sphinx-autobuild"]
5252
ai = [
5353
"openai>=1.0",
@@ -72,13 +72,14 @@ fallback_version = "0.0.0"
7272
testpaths = ["tests"]
7373
pythonpath = ["."]
7474

75-
# Coverage is not installed by the test extra; measure it with, for example,
76-
# uv run --extra test --with pytest-cov python -m pytest --cov
75+
# The test extra installs pytest-cov: `python -m pytest --cov` measures line
76+
# and branch coverage, and fails under 100% (CI runs it so)
7777
[tool.coverage.run]
7878
source = ["gitstats", "scripts"]
7979
branch = true
8080

8181
[tool.coverage.report]
82+
fail_under = 100
8283
show_missing = true
8384
skip_covered = true
8485
exclude_also = ['if __name__ == "__main__":', '@(abc\.)?abstractmethod']

‎tests/test_ai_providers.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ def test_retry_gives_up_after_the_last_attempt():
118118
assert isinstance(error.value.__cause__, RuntimeError)
119119

120120

121+
def test_retry_with_no_attempts_never_calls_the_function():
122+
"""``max_retries`` counts the attempts: with none, the function is not
123+
called at all and nothing comes back."""
124+
provider = _EchoProvider({"max_retries": 0, "retry_delay": 0})
125+
func = MagicMock(return_value="ok")
126+
with patch("gitstats.ai_providers.time.sleep") as sleep:
127+
assert provider._retry_with_backoff(func) is None
128+
func.assert_not_called()
129+
sleep.assert_not_called()
130+
131+
121132
# ── OpenAI ───────────────────────────────────────────────────────────────
122133

123134

‎tests/test_badge.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from gitstats import load_config
1212
from gitstats.badge import (
13+
_HEALTH,
1314
BADGE_FILENAME,
1415
BADGES_DIRNAME,
1516
STYLE_NAMES,
@@ -270,6 +271,20 @@ def test_health_badge_dot_replaces_terminal_prefix(mock_data_collector):
270271
assert ">//</text>" not in svg
271272

272273

274+
def test_health_last_tier_takes_what_no_limit_covers(mock_data_collector, monkeypatch):
275+
"""The last tier is the catch-all: even without its open limit, an age
276+
past every limit still falls to it."""
277+
monkeypatch.setattr("gitstats.badge._HEALTH", _HEALTH[:2])
278+
health = composite_badges(
279+
mock_data_collector, "gitstats", "", "flat", now=datetime.datetime(2025, 6, 1)
280+
)["health"]
281+
assert (health.label, health.message, health.color) == (
282+
"quiet",
283+
"last commit 2 years ago",
284+
"#9a6700",
285+
)
286+
287+
273288
# ── create_badges ────────────────────────────────────────────────────────
274289

275290

‎tests/test_gallery_repos.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,21 @@ def test_load_repos_reads_only_a_yml_file_here(repos_script, tmp_path, monkeypat
167167
monkeypatch.chdir(inside)
168168
with pytest.raises(repos_script.RepoListError, match="under the current directory"):
169169
repos_script.load_repos(name)
170+
171+
172+
def test_load_repos_rejects_a_file_commonpath_cannot_compare(repos_script, tmp_path, monkeypatch):
173+
# A list whose real path lands somewhere commonpath() cannot compare to
174+
# the current directory (another drive on Windows) is not under it:
175+
# os.path.commonpath() raises ValueError for that case.
176+
monkeypatch.chdir(tmp_path)
177+
(tmp_path / "repos.yml").write_text("groups: []\n", encoding="utf-8")
178+
real_realpath = os.path.realpath
179+
180+
def fake_realpath(path):
181+
if str(path).endswith("repos.yml"):
182+
return r"D:\elsewhere\repos.yml"
183+
return real_realpath(path)
184+
185+
monkeypatch.setattr(repos_script.os.path, "realpath", fake_realpath)
186+
with pytest.raises(repos_script.RepoListError, match="under the current directory"):
187+
repos_script.load_repos("repos.yml")

‎tests/test_generate_gallery_portfolio.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,33 @@ def test_add_note_stays_in_its_directory(gallery_script, temp_dir):
417417
assert gallery_script.add_note(inside, outside, "NOTE") is False
418418
assert gallery_script.add_note(inside, os.path.join(inside, "..", "outside.html"), "N") is False
419419
assert _read(outside) == "<p>a</p></body>"
420+
421+
422+
def test_add_note_skips_a_page_commonpath_cannot_compare(gallery_script, temp_dir, monkeypatch):
423+
# A page whose real path lands somewhere commonpath() cannot compare to
424+
# the gallery (another drive on Windows) is not inside it:
425+
# os.path.commonpath() raises ValueError for that case.
426+
page = os.path.join(temp_dir, "page.html")
427+
with open(page, "w", encoding="utf-8") as f:
428+
f.write("<p>a</p></body>")
429+
real_realpath = os.path.realpath
430+
431+
def fake_realpath(path):
432+
if path.endswith("page.html"):
433+
return r"D:\outside\page.html"
434+
return real_realpath(path)
435+
436+
monkeypatch.setattr(gallery_script.os.path, "realpath", fake_realpath)
437+
assert gallery_script.add_note(temp_dir, page, "NOTE") is False
438+
assert _read(page) == "<p>a</p></body>"
439+
440+
441+
def test_add_report_notes_skips_a_report_without_a_folder(gallery_script, temp_dir):
442+
gallery = os.path.join(temp_dir, "gallery")
443+
os.makedirs(gallery)
444+
summaries = [
445+
{"report_path": "missing/index.html", "name": "no folder"},
446+
{"report_path": "../index.html", "name": "outside the gallery"},
447+
{"report_path": "./index.html", "name": "the gallery itself"},
448+
]
449+
assert gallery_script.add_report_notes(gallery, summaries, "") == 0

‎tests/test_main.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import sys
1010
from pathlib import Path
11+
from types import SimpleNamespace
1112
from unittest.mock import MagicMock, patch
1213

1314
import pytest
@@ -1489,6 +1490,53 @@ def test_main_default_outputpath(git_repo_minimal, temp_dir):
14891490
assert os.path.isdir("gitstats-report")
14901491

14911492

1493+
@pytest.mark.skipif(
1494+
sys.version_info < (3, 12),
1495+
reason="argparse before 3.12 rejects a positional that follows an option",
1496+
)
1497+
def test_main_outputpath_after_an_option(git_repo_minimal, temp_dir):
1498+
"""`gitstats repo -f json out`: argparse itself hands the last path to
1499+
outputpath, so main() has nothing to move out of gitpath."""
1500+
import gitstats
1501+
import gitstats.main
1502+
1503+
cfg = dict(gitstats.DEFAULT_CONFIG, ai_enabled=False)
1504+
gitstats._config = cfg
1505+
gitstats.main.conf = cfg
1506+
1507+
output = os.path.join(temp_dir, "report")
1508+
1509+
with patch.object(sys, "argv", ["gitstats", git_repo_minimal, "-f", "json", output]):
1510+
ret = main()
1511+
assert ret == 0
1512+
assert os.path.isfile(os.path.join(output, "index.html"))
1513+
assert os.path.isfile(f"{output}.json") # the JSON goes next to the report
1514+
1515+
1516+
def test_main_keeps_an_outputpath_argparse_filled(git_repo_minimal, temp_dir, monkeypatch):
1517+
"""Same contract on every Python: when the parser already filled outputpath,
1518+
main() leaves gitpath alone instead of moving its last entry."""
1519+
import gitstats
1520+
import gitstats.main
1521+
1522+
cfg = dict(gitstats.DEFAULT_CONFIG, ai_enabled=False)
1523+
gitstats._config = cfg
1524+
gitstats.main.conf = cfg
1525+
1526+
output = os.path.join(temp_dir, "report")
1527+
args = gitstats.main.get_parser().parse_args([git_repo_minimal, "-f", "json"])
1528+
args.outputpath = output
1529+
monkeypatch.setattr(
1530+
gitstats.main, "get_parser", lambda: SimpleNamespace(parse_args=lambda: args)
1531+
)
1532+
1533+
ret = main()
1534+
assert ret == 0
1535+
assert args.gitpath == [git_repo_minimal]
1536+
assert os.path.isfile(os.path.join(output, "index.html"))
1537+
assert os.path.isfile(f"{output}.json")
1538+
1539+
14921540
def test_main_with_config_override(git_repo_minimal, temp_dir):
14931541
"""Test main() with -c overrides."""
14941542
import gitstats

‎tests/test_report_creator.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,26 @@ def test_contributor_timeline_years_and_bots(mock_data_collector):
10711071
assert "width: 0.000%" in html
10721072

10731073

1074+
def test_contributor_timeline_places_a_month_inside_the_collapsed_stretch(mock_data_collector):
1075+
"""The stretch is squeezed into its slot, not cut out: a month inside it
1076+
lands inside the band, in proportion, never past it."""
1077+
months = {f"2010-{m:02d}": 2 for m in range(1, 13)}
1078+
months.update({f"2013-{m:02d}": 3 for m in range(1, 13)})
1079+
mock_data_collector.commits_by_month = months
1080+
mock_data_collector.author_of_month = {m: {"Alice Smith": n} for m, n in months.items()}
1081+
# a commit the monthly totals do not have, in the quiet 2011-01 .. 2012-12
1082+
mock_data_collector.author_of_month["2011-07"] = {"Bob Jones": 1}
1083+
html = HTMLReportCreator()._contributor_timeline_html(
1084+
mock_data_collector, ["Alice Smith", "Bob Jones"]
1085+
)
1086+
# 48 months, the 24 quiet ones in one slot of 25: the band is at 48%, 4% wide
1087+
assert '<span class="timeline-gap is-collapsed" style="left: 48.000%; width: 4.000%" ' in html
1088+
# 2011-07 is 6.5 months into the 24 of the stretch: that far into the slot,
1089+
# (12 + 6.5 / 24) / 25 of the axis
1090+
assert 'aria-label="Bob Jones: 15 commits, 2011-07 to 2011-07"' in html
1091+
assert 'class="timeline-mark" style="left: 49.083%;' in html
1092+
1093+
10741094
# ── HTMLReportCreator.create_files_html ──────────────────────────────────
10751095

10761096

0 commit comments

Comments
 (0)