Public Access
DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 48s
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 48s
This commit was merged in pull request #65.
This commit is contained in:
+165
-142
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
"""Unit tests for devx/tools/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -8,7 +8,12 @@ from click.testing import CliRunner
|
||||
from devx.tools.generate_badges import (
|
||||
COLOR_HEX,
|
||||
cli,
|
||||
collect_coverage_and_tests,
|
||||
collect_doc_coverage,
|
||||
collect_quality,
|
||||
coverage_color,
|
||||
detect_coverage_target,
|
||||
detect_package_name,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
@@ -17,10 +22,81 @@ from devx.tools.generate_badges import (
|
||||
make_badge,
|
||||
read_version,
|
||||
render_svg,
|
||||
resolve_repo_root,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveRepoRoot:
|
||||
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
|
||||
result = resolve_repo_root()
|
||||
assert result == Path.cwd()
|
||||
|
||||
|
||||
class TestDetectPackageName:
|
||||
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
# No __init__.py
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
for name in ["zpkg", "apkg"]:
|
||||
d = src / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "apkg"
|
||||
|
||||
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
src.mkdir(parents=True)
|
||||
(src / "README.md").write_text("not a package")
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
|
||||
class TestDetectCoverageTarget:
|
||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
|
||||
)
|
||||
assert detect_coverage_target(tmp_path) == "src/devx"
|
||||
|
||||
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
|
||||
assert detect_coverage_target(tmp_path) == "src/mypkg"
|
||||
|
||||
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_coverage_target(tmp_path) is None
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("devx.tools.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
@@ -157,100 +233,116 @@ class TestDocCoverageColor:
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "0.5.0"
|
||||
def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.5.0"\n')
|
||||
assert read_version(tmp_path) == "0.5.0"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = "no version here\n"
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("no version here\n")
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
|
||||
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
|
||||
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
# Package detected but __init__.py doesn't exist (edge case)
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
|
||||
class TestFindPackageInit:
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_src_dir(self, mock_root: MagicMock) -> None:
|
||||
"""Returns None when src/ directory doesn't exist."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectCoverageAndTests:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_extracts_coverage_and_tests(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "100%"
|
||||
assert tests["message"] == "1018 passing"
|
||||
|
||||
mock_src = MagicMock()
|
||||
mock_src.exists.return_value = False
|
||||
mock_root.__truediv__ = MagicMock(return_value=mock_src)
|
||||
assert _find_package_init() is None
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_returns_unknown_when_no_match(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "garbled output", "some error")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert tests["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns None when no __init__.py has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
|
||||
def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert cov["color"] == "lightgrey"
|
||||
assert tests["message"] == "unknown"
|
||||
assert tests["color"] == "lightgrey"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "__init__.py").write_text("# no version here\n")
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
assert _find_package_init() is None
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns the __init__.py that has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectDocCoverage:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "100%"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
pkg_dir = src_dir / "mypkg"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(src_dir / "__init__.py").write_text("# no version\n")
|
||||
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
result = _find_package_init()
|
||||
assert result is not None
|
||||
assert "__version__" in result.read_text()
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "no doc coverage", "error")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Handles OSError when reading init files."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
init_file = src_dir / "__init__.py"
|
||||
init_file.write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
# Patch Path.read_text to raise OSError
|
||||
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
|
||||
result = _find_package_init()
|
||||
assert result is None
|
||||
class TestCollectQuality:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "", "")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
assert badge["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "some error")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "F"
|
||||
assert badge["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "No module named ruff")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.collect_quality")
|
||||
@patch("devx.tools.generate_badges.collect_doc_coverage")
|
||||
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_pkg: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_cov_tests: MagicMock,
|
||||
mock_doc: MagicMock,
|
||||
mock_quality: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_cov_tests.return_value = (
|
||||
make_badge("coverage", "100%", "brightgreen"),
|
||||
make_badge("tests", "573 passing", "brightgreen"),
|
||||
)
|
||||
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
|
||||
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
|
||||
|
||||
badges = generate_badges(tmp_path, repo_root=tmp_path)
|
||||
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify SVG files were written
|
||||
for name in expected:
|
||||
svg_file = tmp_path / f"{name}.svg"
|
||||
assert svg_file.exists()
|
||||
@@ -258,78 +350,10 @@ class TestGenerateBadges:
|
||||
assert content.startswith("<svg")
|
||||
assert "</svg>" in content
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "573 passing", "brightgreen"),
|
||||
@@ -339,7 +363,6 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
|
||||
Reference in New Issue
Block a user