Public Access
DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s
This commit was merged in pull request #103.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""Unit tests for devx.tools.check_agent_docs."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_agent_docs import (
|
||||
DEFAULT_REPO_PATH_PREFIXES,
|
||||
DEFAULT_SCAN_DIRS,
|
||||
DEFAULT_SCAN_EXTENSIONS,
|
||||
DEFAULT_SCAN_FILES,
|
||||
MIN_PATH_REF_LENGTH_DEFAULT,
|
||||
_check_file,
|
||||
_collect_doc_files,
|
||||
_is_legitimate_ref,
|
||||
_should_skip,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
class TestShouldSkip:
|
||||
def test_skips_excluded_path(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "docs" / "retrospectives" / "r.md"
|
||||
f.parent.mkdir(parents=True)
|
||||
f.write_text("")
|
||||
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is True
|
||||
|
||||
def test_does_not_skip_normal(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "docs" / "guide.md"
|
||||
f.parent.mkdir(parents=True)
|
||||
f.write_text("")
|
||||
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is False
|
||||
|
||||
def test_returns_false_for_path_outside_repo(self, tmp_path: Path) -> None:
|
||||
f = Path("/tmp/some_other_path/guide.md")
|
||||
assert _should_skip(f, [], tmp_path) is False
|
||||
|
||||
|
||||
class TestIsLegitimateRef:
|
||||
def test_legitimate_legacy(self) -> None:
|
||||
assert _is_legitimate_ref("This is legacy code", ["legacy"]) is True
|
||||
|
||||
def test_not_legitimate(self) -> None:
|
||||
assert _is_legitimate_ref("Use this file", ["legacy"]) is False
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert _is_legitimate_ref("This is LEGACY", ["legacy"]) is True
|
||||
|
||||
|
||||
class TestCollectDocFiles:
|
||||
def test_collects_devin_and_docs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".devin").mkdir()
|
||||
(tmp_path / ".devin" / "guide.md").write_text("")
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "api.md").write_text("")
|
||||
(tmp_path / "README.md").write_text("")
|
||||
|
||||
files = _collect_doc_files(tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
|
||||
names = {f.name for f in files}
|
||||
assert "guide.md" in names
|
||||
assert "api.md" in names
|
||||
assert "README.md" in names
|
||||
|
||||
def test_excludes_paths(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "docs" / "retrospectives").mkdir(parents=True)
|
||||
(tmp_path / "docs" / "retrospectives" / "r.md").write_text("")
|
||||
(tmp_path / "docs" / "guide.md").write_text("")
|
||||
|
||||
files = _collect_doc_files(
|
||||
tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, ["docs/retrospectives"]
|
||||
)
|
||||
names = {f.name for f in files}
|
||||
assert "guide.md" in names
|
||||
assert "r.md" not in names
|
||||
|
||||
def test_deduplicates(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "api.md").write_text("")
|
||||
|
||||
files = _collect_doc_files(tmp_path, ["docs", "docs"], DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
class TestCheckFile:
|
||||
def test_detects_deleted_file_ref(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/old.py for details.\n")
|
||||
issues = _check_file(
|
||||
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
)
|
||||
assert any("deleted file" in i for i in issues)
|
||||
|
||||
def test_detects_nonexistent_file_ref(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/nonexistent.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
assert any("non-existent file" in i for i in issues)
|
||||
|
||||
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "scripts").mkdir()
|
||||
(tmp_path / "scripts" / "exists.py").write_text("")
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/exists.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
assert issues == []
|
||||
|
||||
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
|
||||
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||
issues = _check_file(
|
||||
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
)
|
||||
assert any("deprecated pattern" in i for i in issues)
|
||||
|
||||
def test_legitimate_ref_skips_deprecated(self, tmp_path: Path) -> None:
|
||||
# Create the referenced file so the non-existent check doesn't trigger
|
||||
secrets = tmp_path / "ansible" / "envs" / "prod" / "secrets.yml"
|
||||
secrets.parent.mkdir(parents=True)
|
||||
secrets.write_text("")
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
|
||||
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||
issues = _check_file(
|
||||
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
)
|
||||
assert issues == []
|
||||
|
||||
def test_unicode_error_returns_empty(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_bytes(b"\xff\xfe\x00\x00")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
assert issues == []
|
||||
|
||||
def test_skips_short_ref(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See a.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5)
|
||||
# "a.py" is only 4 chars, below min_path_ref_length
|
||||
assert issues == []
|
||||
|
||||
def test_skips_ref_without_repo_prefix(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See vendor/some/long/path.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
# "vendor/" is not in repo_path_prefixes
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_agent_docs._load_config", return_value={}),
|
||||
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
|
||||
def test_fails_when_stale_ref(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "guide.md").write_text("See scripts/deleted.py\n")
|
||||
cfg = {"deleted_files": ["scripts/deleted.py"]}
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_load_config_returns_empty_when_not_dict(self) -> None:
|
||||
from devx.tools.check_agent_docs import _load_config
|
||||
|
||||
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": "not a dict"}):
|
||||
assert _load_config() == {}
|
||||
|
||||
def test_load_config_returns_dict_when_valid(self) -> None:
|
||||
from devx.tools.check_agent_docs import _load_config
|
||||
|
||||
cfg = {"scan_dirs": ["custom"]}
|
||||
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": cfg}):
|
||||
assert _load_config() == cfg
|
||||
|
||||
def test_invalid_regex_pattern_skipped(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
|
||||
cfg = {"deprecated_patterns": ["[invalid"]}
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_custom_scan_dirs(self, tmp_path: Path) -> None:
|
||||
custom = tmp_path / "custom_docs"
|
||||
custom.mkdir()
|
||||
(custom / "guide.md").write_text("See scripts/deleted.py\n")
|
||||
cfg = {"scan_dirs": ["custom_docs"], "deleted_files": ["scripts/deleted.py"]}
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for devx.ci.check_auto_merge_ready."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.check_auto_merge_ready import (
|
||||
cli,
|
||||
get_pr_title_from_gitea,
|
||||
get_vikunja_title_optional,
|
||||
is_branch_behind_master,
|
||||
)
|
||||
|
||||
|
||||
class TestIsBranchBehindMaster:
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None:
|
||||
# First: fetch (ok), second: ahead count (ok), third: behind count = 0
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="0\n", stderr=""),
|
||||
]
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_true_when_behind(self, mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="5\n", stderr=""),
|
||||
]
|
||||
assert is_branch_behind_master("feature") is True
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="error"),
|
||||
]
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30)
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="not_a_number\n", stderr=""),
|
||||
]
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = FileNotFoundError("git not found")
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||
def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None:
|
||||
# fetch ok, ahead count ok, behind count command fails
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="error"),
|
||||
]
|
||||
assert is_branch_behind_master("feature") is False
|
||||
|
||||
|
||||
class TestGetPrTitleFromGitea:
|
||||
def test_returns_none_without_token(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert get_pr_title_from_gitea("owner/repo", 1) is None
|
||||
|
||||
def test_returns_none_with_invalid_repo(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
assert get_pr_title_from_gitea("invalid", 1) is None
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
def test_fetches_title(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||
assert result == "DEVX-1: Fix bug"
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.side_effect = Exception("API error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetVikunjaTitleOptional:
|
||||
def test_returns_none_without_token(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert get_vikunja_title_optional("DEVX-1") is None
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||
def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||
result = get_vikunja_title_optional("DEVX-1")
|
||||
assert result == "Fix bug"
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||
def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||
result = get_vikunja_title_optional("DEVX-1")
|
||||
assert result is None
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||
def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
mock_client = MagicMock()
|
||||
# First page: full page of non-matching tasks, second page: match
|
||||
page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||
page2 = [{"identifier": "DEVX-99", "title": "Found it"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||
result = get_vikunja_title_optional("DEVX-99")
|
||||
assert result == "Found it"
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||
def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||
result = get_vikunja_title_optional("DEVX-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_fails_without_task_id(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True):
|
||||
result = runner.invoke(cli, ["--branch", "no-task-id-here"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_local_mode_no_pr_title(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
):
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"])
|
||||
assert result.exit_code == 0
|
||||
assert "local mode" in result.output
|
||||
|
||||
def test_validates_pr_title_format(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
):
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"])
|
||||
assert result.exit_code != 0
|
||||
assert "format" in result.output.lower() or "mismatch" in result.output.lower()
|
||||
|
||||
def test_passes_with_valid_title(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
):
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"])
|
||||
assert result.exit_code == 0
|
||||
assert "satisfied" in result.output
|
||||
|
||||
def test_skip_behind_check(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_fails_when_behind_master(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "behind" in result.output.lower()
|
||||
|
||||
def test_skip_vikunja(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_fetches_pr_title_from_gitea(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "from Gitea" in result.output
|
||||
|
||||
def test_fails_when_pr_number_but_no_title(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Could not fetch" in result.output
|
||||
|
||||
def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Could not find Vikunja task" in result.output
|
||||
|
||||
def test_passes_with_vikunja_title_match(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Vikunja title match OK" in result.output
|
||||
|
||||
def test_fails_with_vikunja_title_mismatch(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "does not match Vikunja" in result.output
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Unit tests for devx.tools.check_mutable_globals."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_mutable_globals import (
|
||||
DEFAULT_SCAN_DIRS,
|
||||
DEFAULT_SKIP_DIRS,
|
||||
_load_config,
|
||||
_should_skip,
|
||||
cli,
|
||||
find_mutable_globals,
|
||||
)
|
||||
|
||||
|
||||
class TestFindMutableGlobals:
|
||||
def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_SEEN" in issues[0]
|
||||
assert "set()" in issues[0]
|
||||
|
||||
def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_CACHE" in issues[0]
|
||||
|
||||
def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "PATHS: list[Path] = []\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "PATHS" in issues[0]
|
||||
|
||||
def test_skips_non_mutable_globals(self, tmp_path: Path) -> None:
|
||||
source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_SEEN" in issues[0]
|
||||
|
||||
def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_DATA: dict[str, int] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_detects_path_type_annotation(self, tmp_path: Path) -> None:
|
||||
source = "_FILES: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_known_safe_exception(self, tmp_path: Path) -> None:
|
||||
source = "_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
known_safe = {("mod.py", 1, "_SEEN")}
|
||||
issues = find_mutable_globals(f, tmp_path, known_safe)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text("def broken(:\n")
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_detects_mutable_literal_list(self, tmp_path: Path) -> None:
|
||||
source = "SEEN_PATHS: list[Path] = []\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_detects_mutable_literal_set(self, tmp_path: Path) -> None:
|
||||
source = "REGISTRY: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_skips_function_definitions(self, tmp_path: Path) -> None:
|
||||
source = "def foo():\n pass\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_handles_assign_with_name_target(self, tmp_path: Path) -> None:
|
||||
source = "SEEN_PATHS = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "SEEN_PATHS" in issues[0]
|
||||
|
||||
def test_skips_annotation_without_value(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any]\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_skips_attribute_call(self, tmp_path: Path) -> None:
|
||||
# collections.defaultdict is an Attribute call, not a Name call
|
||||
source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
# Attribute calls are skipped (pass), so not flagged as mutable literal
|
||||
assert issues == []
|
||||
|
||||
def test_multiple_assign_targets(self, tmp_path: Path) -> None:
|
||||
source = "SEEN = CACHE = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
# Both SEEN and CACHE should be flagged
|
||||
assert len(issues) == 2
|
||||
|
||||
|
||||
class TestShouldSkip:
|
||||
def test_skips_pycache(self) -> None:
|
||||
assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||
|
||||
def test_skips_venv(self) -> None:
|
||||
assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||
|
||||
def test_does_not_skip_normal(self) -> None:
|
||||
assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None:
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||
assert known_safe == set()
|
||||
|
||||
def test_reads_config_from_pyproject(self) -> None:
|
||||
cfg = {
|
||||
"check_mutable_globals": {
|
||||
"scan_dirs": ["src", "tests"],
|
||||
"skip_dirs": ["__pycache__", ".tox"],
|
||||
"known_safe": ["src/mod.py:10:_CACHE"],
|
||||
}
|
||||
}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == ["src", "tests"]
|
||||
assert ".tox" in skip_dirs
|
||||
assert ("src/mod.py", 10, "_CACHE") in known_safe
|
||||
|
||||
def test_returns_defaults_when_cfg_not_dict(self) -> None:
|
||||
with patch(
|
||||
"devx.tools.check_mutable_globals._load_pyproject_devx",
|
||||
return_value={"check_mutable_globals": "not a dict"},
|
||||
):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||
assert known_safe == set()
|
||||
|
||||
def test_known_safe_with_invalid_line_number(self) -> None:
|
||||
cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
_, _, known_safe = _load_config()
|
||||
assert known_safe == set()
|
||||
|
||||
def test_scan_dirs_not_list_returns_default(self) -> None:
|
||||
cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
scan_dirs, _, _ = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
|
||||
def test_fails_when_issues_found(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "src"
|
||||
scan_dir.mkdir()
|
||||
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "custom"
|
||||
scan_dir.mkdir()
|
||||
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, ["--scan-dir", "custom"])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "src"
|
||||
pycache = scan_dir / "__pycache__"
|
||||
pycache.mkdir(parents=True)
|
||||
(pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Unit tests for devx.tools.check_pyproject_deps."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_pyproject_deps import check_deps, cli
|
||||
|
||||
|
||||
class TestCheckDeps:
|
||||
def test_no_issues_when_all_documented(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# HTTP client
|
||||
"requests>=2.0"
|
||||
# CLI framework
|
||||
"click>=8.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert issues == []
|
||||
|
||||
def test_finds_undocumented_dependency(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# HTTP client
|
||||
"requests>=2.0"
|
||||
"click>=8.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert len(issues) == 1
|
||||
assert "click" in issues[0]
|
||||
|
||||
def test_finds_multiple_undocumented(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
"requests>=2.0"
|
||||
"click>=8.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert len(issues) == 2
|
||||
|
||||
def test_handles_optional_dependencies(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.optional-dependencies]
|
||||
ci = [
|
||||
# Test runner
|
||||
"pytest>=8",
|
||||
"pytest-cov>=4",
|
||||
]
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert len(issues) == 1
|
||||
assert "pytest-cov" in issues[0]
|
||||
|
||||
def test_returns_file_not_found_for_missing_file(self, tmp_path: Path) -> None:
|
||||
issues = check_deps(tmp_path / "nonexistent.toml")
|
||||
assert len(issues) == 1
|
||||
assert "not found" in issues[0]
|
||||
|
||||
def test_empty_deps_section_no_issues(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert issues == []
|
||||
|
||||
def test_skips_non_deps_sections(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project]
|
||||
name = "test"
|
||||
version = "0.1.0"
|
||||
|
||||
[project.dependencies]
|
||||
# HTTP
|
||||
"requests>=2.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert issues == []
|
||||
|
||||
def test_handles_dash_prefixed_deps(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# HTTP client
|
||||
-requests>=2.0
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert issues == []
|
||||
|
||||
def test_empty_lines_in_deps_section(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
|
||||
# HTTP client
|
||||
"requests>=2.0"
|
||||
|
||||
# CLI
|
||||
"click>=8.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
assert issues == []
|
||||
|
||||
def test_non_dep_non_comment_line_resets_prev(self, tmp_path: Path) -> None:
|
||||
# A line that's not a comment, not a dep, not empty — resets prev_was_comment
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# Comment
|
||||
ci = [
|
||||
"requests>=2.0",
|
||||
]
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
# "requests" is preceded by a comment, but the `ci = [` line resets prev_was_comment
|
||||
# Actually `ci = [` doesn't start with - or ", so it hits the else branch
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_section_transition_exits_deps(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# HTTP
|
||||
"requests>=2.0"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Test runner
|
||||
"pytest>=8"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
# Both deps are documented
|
||||
assert issues == []
|
||||
|
||||
def test_deps_after_other_section_not_checked(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project]
|
||||
name = "test"
|
||||
|
||||
[project.dependencies]
|
||||
# Documented
|
||||
"requests>=2.0"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
"undocumented-dep>=1.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
issues = check_deps(f)
|
||||
# The "undocumented-dep" is in [tool.ruff], not a deps section
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_when_all_documented(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# HTTP client
|
||||
"requests>=2.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
with __import__("contextlib").chdir(tmp_path):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
|
||||
def test_fails_when_undocumented(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
"requests>=2.0"
|
||||
"""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
with __import__("contextlib").chdir(tmp_path):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_custom_file_option(self, tmp_path: Path) -> None:
|
||||
content = """\
|
||||
[project.dependencies]
|
||||
# Documented
|
||||
"requests>=2.0"
|
||||
"""
|
||||
f = tmp_path / "custom.toml"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--file", str(f)])
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Unit tests for devx.tools.check_test_coverage."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from devx.tools.check_test_coverage import (
|
||||
BUILTIN_RULES,
|
||||
DEFAULT_SKIP_EXTENSIONS,
|
||||
DEFAULT_TEST_INDICATORS,
|
||||
_changed_files,
|
||||
_find_missing_tests,
|
||||
_is_test_file,
|
||||
_load_rules,
|
||||
_resolve_test_path,
|
||||
_should_skip_file,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestIsTestFile:
|
||||
def test_tests_dir(self) -> None:
|
||||
assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True
|
||||
|
||||
def test_test_prefix(self) -> None:
|
||||
assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True
|
||||
|
||||
def test_test_suffix(self) -> None:
|
||||
assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True
|
||||
|
||||
def test_non_test_file(self) -> None:
|
||||
assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False
|
||||
|
||||
|
||||
class TestShouldSkipFile:
|
||||
def test_skips_dotfiles(self) -> None:
|
||||
assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||
|
||||
def test_skips_markdown(self) -> None:
|
||||
assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||
|
||||
def test_skips_yaml(self) -> None:
|
||||
assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||
|
||||
def test_does_not_skip_python(self) -> None:
|
||||
assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False
|
||||
|
||||
def test_skips_by_pattern(self) -> None:
|
||||
assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True
|
||||
|
||||
def test_skips_by_glob_pattern(self) -> None:
|
||||
assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True
|
||||
|
||||
|
||||
class TestResolveTestPath:
|
||||
def test_resolves_name(self, tmp_path: Path) -> None:
|
||||
result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path)
|
||||
assert result == tmp_path / "tests" / "unit" / "test_foo"
|
||||
|
||||
def test_resolves_module(self, tmp_path: Path) -> None:
|
||||
result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path)
|
||||
assert result == tmp_path / "tests" / "unit" / "test_pkg_foo"
|
||||
|
||||
def test_resolves_package_prefix(self, tmp_path: Path) -> None:
|
||||
result = _resolve_test_path(
|
||||
"tests/unit/test_{package_prefix}_{name}",
|
||||
"scripts/utils/secrets.py",
|
||||
tmp_path,
|
||||
)
|
||||
assert result == tmp_path / "tests" / "unit" / "test_utils_secrets"
|
||||
|
||||
def test_normalizes_hyphens(self, tmp_path: Path) -> None:
|
||||
result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path)
|
||||
assert result == tmp_path / "tests" / "test_my_script"
|
||||
|
||||
|
||||
class TestFindMissingTests:
|
||||
def test_finds_missing_test(self, tmp_path: Path) -> None:
|
||||
files = ["scripts/foo.py"]
|
||||
rules = BUILTIN_RULES
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
assert "scripts/foo.py" in missing
|
||||
|
||||
def test_no_missing_when_test_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
|
||||
files = ["scripts/foo.py"]
|
||||
rules = BUILTIN_RULES
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
assert missing == {}
|
||||
|
||||
def test_skips_test_files(self, tmp_path: Path) -> None:
|
||||
files = ["tests/unit/test_foo.py"]
|
||||
rules = BUILTIN_RULES
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
assert missing == {}
|
||||
|
||||
def test_skips_non_python_files(self, tmp_path: Path) -> None:
|
||||
files = ["README.md", "config.yml"]
|
||||
rules = BUILTIN_RULES
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
assert missing == {}
|
||||
|
||||
def test_no_rule_no_requirement(self, tmp_path: Path) -> None:
|
||||
files = ["unknown_type.xyz"]
|
||||
rules = BUILTIN_RULES
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
assert missing == {}
|
||||
|
||||
|
||||
class TestChangedFiles:
|
||||
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||
def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0)
|
||||
files = _changed_files(staged_only=True, repo_root=tmp_path)
|
||||
assert files == ["file1.py", "file2.py"]
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "--cached" in cmd
|
||||
|
||||
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||
def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0)
|
||||
files = _changed_files(staged_only=False, repo_root=tmp_path)
|
||||
assert files == ["file1.py"]
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "origin/master...HEAD" in cmd
|
||||
|
||||
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||
def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
# First call fails, second succeeds
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="", returncode=1),
|
||||
MagicMock(stdout="file1.py\n", returncode=0),
|
||||
]
|
||||
files = _changed_files(staged_only=False, repo_root=tmp_path)
|
||||
assert files == ["file1.py"]
|
||||
|
||||
|
||||
class TestLoadRules:
|
||||
def test_defaults_when_no_config(self) -> None:
|
||||
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}):
|
||||
rules, skip, indicators, skip_ext = _load_rules()
|
||||
assert rules == BUILTIN_RULES
|
||||
assert skip == []
|
||||
assert indicators == DEFAULT_TEST_INDICATORS
|
||||
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
|
||||
|
||||
def test_custom_rules(self) -> None:
|
||||
cfg = {
|
||||
"check_test_coverage": {
|
||||
"rules": [
|
||||
{
|
||||
"source_pattern": "lib/*.py",
|
||||
"test_paths": ["tests/test_{name}"],
|
||||
"description": "Missing: tests/test_{name}",
|
||||
}
|
||||
],
|
||||
"skip_patterns": ["__init__.py"],
|
||||
}
|
||||
}
|
||||
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
|
||||
rules, skip, indicators, skip_ext = _load_rules()
|
||||
assert len(rules) == 1
|
||||
assert rules[0]["source_pattern"] == "lib/*.py"
|
||||
assert "__init__.py" in skip
|
||||
|
||||
def test_returns_defaults_when_cfg_not_dict(self) -> None:
|
||||
with patch(
|
||||
"devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"}
|
||||
):
|
||||
rules, skip, indicators, skip_ext = _load_rules()
|
||||
assert rules == BUILTIN_RULES
|
||||
assert skip == []
|
||||
|
||||
def test_skip_extensions_not_list_returns_default(self) -> None:
|
||||
cfg = {"check_test_coverage": {"skip_extensions": "not a list"}}
|
||||
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
|
||||
_, _, _, skip_ext = _load_rules()
|
||||
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
|
||||
|
||||
def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None:
|
||||
files = ["scripts/foo.py"]
|
||||
rules = [
|
||||
{
|
||||
"source_pattern": "scripts/*.py",
|
||||
"test_paths": "not a list",
|
||||
"description": "Missing test",
|
||||
}
|
||||
]
|
||||
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||
# Rule matches but test_paths is not a list, so it's skipped — no missing
|
||||
assert missing == {}
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_no_changed_files(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("devx.tools.check_test_coverage._changed_files", return_value=[]),
|
||||
patch(
|
||||
"devx.tools.check_test_coverage._load_rules",
|
||||
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
|
||||
def test_all_have_tests(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
|
||||
with (
|
||||
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||
patch(
|
||||
"devx.tools.check_test_coverage._load_rules",
|
||||
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
|
||||
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||
patch(
|
||||
"devx.tools.check_test_coverage._load_rules",
|
||||
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 1
|
||||
|
||||
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||
patch(
|
||||
"devx.tools.check_test_coverage._load_rules",
|
||||
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main(["--warn-only"]) == 0
|
||||
@@ -356,6 +356,6 @@ class TestListBranches:
|
||||
class TestWhoami:
|
||||
def test_whoami(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
||||
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert cli.whoami() == "emil"
|
||||
assert cli.whoami() == "testuser"
|
||||
|
||||
Reference in New Issue
Block a user