"""Unit tests for devx.ci.check_pr_size.""" from unittest.mock import MagicMock, patch from click.testing import CliRunner from devx.ci.check_pr_size import ( check_size, cli, get_diff_stats, has_refactoring_label, is_excluded, ) class TestIsExcluded: def test_excludes_changelog(self) -> None: assert is_excluded("CHANGELOG.md", ["CHANGELOG.md"]) def test_excludes_svg_glob(self) -> None: assert is_excluded("docs/badges/coverage.svg", ["*.svg"]) def test_does_not_exclude_source(self) -> None: assert not is_excluded("src/devx/ci/check_pr_size.py", ["CHANGELOG.md", "*.svg"]) def test_excludes_readme(self) -> None: assert is_excluded("README.md", ["README.md"]) class TestCheckSize: def test_under_limits_passes(self) -> None: stats = [("src/main.py", 100, 50), ("tests/test_main.py", 80, 20)] ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[]) assert ok is True assert "250" in detail # 100+50+80+20 def test_over_lines_fails(self) -> None: stats = [("src/main.py", 300, 300)] ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[]) assert ok is False assert "600" in detail def test_over_files_fails(self) -> None: stats = [(f"src/file{i}.py", 10, 5) for i in range(15)] ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[]) assert ok is False assert "15" in detail def test_excluded_files_not_counted(self) -> None: stats = [("CHANGELOG.md", 500, 500), ("src/main.py", 10, 5)] ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=["CHANGELOG.md"]) assert ok is True assert "15" in detail # only 10+5 def test_empty_stats_passes(self) -> None: ok, detail = check_size([], max_lines=500, max_files=10, excluded_patterns=[]) assert ok is True class TestGetDiffStats: @patch("devx.ci.check_pr_size.subprocess.run") def test_parses_numstat_output(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="10\t5\tsrc/main.py\n20\t10\ttests/test_main.py\n", stderr="", ) stats = get_diff_stats("origin/master", "HEAD") assert len(stats) == 2 assert stats[0] == ("src/main.py", 10, 5) assert stats[1] == ("tests/test_main.py", 20, 10) @patch("devx.ci.check_pr_size.subprocess.run") def test_handles_binary_files(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="-\t-\timage.png\n", stderr="", ) stats = get_diff_stats("origin/master", "HEAD") assert len(stats) == 1 assert stats[0] == ("image.png", 0, 0) @patch("devx.ci.check_pr_size.subprocess.run") def test_empty_output(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") stats = get_diff_stats("origin/master", "HEAD") assert stats == [] @patch("devx.ci.check_pr_size.subprocess.run") def test_git_diff_failure_raises(self, mock_run: MagicMock) -> None: import pytest mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="fatal: bad ref") with pytest.raises(Exception, match="git diff|bad ref"): get_diff_stats("origin/master", "HEAD") @patch("devx.ci.check_pr_size.subprocess.run") def test_malformed_line_skipped(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="not_a_valid_line\n10\t5\tsrc/main.py\n", stderr="", ) stats = get_diff_stats("origin/master", "HEAD") assert len(stats) == 1 assert stats[0] == ("src/main.py", 10, 5) class TestCli: @patch("devx.ci.check_pr_size.subprocess.run") def test_passes_when_small(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="10\t5\tsrc/main.py\n", stderr="", ) runner = CliRunner() result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD"]) assert result.exit_code == 0 assert "OK" in result.output @patch("devx.ci.check_pr_size.subprocess.run") def test_fails_when_too_large(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="300\t300\tsrc/main.py\n", stderr="", ) runner = CliRunner() result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD", "--max-lines", "500"]) assert result.exit_code != 0 assert "600" in result.output @patch("devx.ci.check_pr_size.subprocess.run") @patch("devx.ci.check_pr_size.has_refactoring_label", return_value=True) def test_bypasses_with_refactoring_label(self, mock_label: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="300\t300\tsrc/main.py\n", stderr="", ) runner = CliRunner() result = runner.invoke( cli, ["--base", "origin/master", "--head", "HEAD", "--repo", "owner/repo", "--pr-number", "42"], ) assert result.exit_code == 0 assert "bypassed" in result.output.lower() class TestHasRefactoringLabel: @patch("devx.ci.check_pr_size.GiteaClient") @patch("devx.ci.check_pr_size.get_ci_token", return_value="fake-token") def test_returns_true_when_label_present(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None: mock_client = mock_client_cls.return_value mock_client.get_pr.return_value = {"labels": [{"name": "refactoring"}, {"name": "bug"}]} assert has_refactoring_label("owner/repo", 42) is True @patch("devx.ci.check_pr_size.GiteaClient") @patch("devx.ci.check_pr_size.get_ci_token", return_value="fake-token") def test_returns_false_when_label_absent(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None: mock_client = mock_client_cls.return_value mock_client.get_pr.return_value = {"labels": [{"name": "bug"}]} assert has_refactoring_label("owner/repo", 42) is False @patch("devx.ci.check_pr_size.get_ci_token", side_effect=Exception("no token")) def test_returns_false_on_error(self, mock_token: MagicMock) -> None: assert has_refactoring_label("owner/repo", 42) is False