Files
devx/tests/unit/test_doc_coverage.py
T
emil ee80c27631
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
2026-06-28 16:35:59 +00:00

198 lines
8.7 KiB
Python

"""Unit tests for devx.ci.doc_coverage."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.ci.doc_coverage import (
check_command_documented,
check_module_documented,
extract_cli_commands,
main,
)
# Path to devx's own source directory (for testing)
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
class TestExtractCliCommands:
def test_extracts_commands(self) -> None:
commands = extract_cli_commands(DEVX_SRC_DIR)
# devx CLI has commands under ci, tools, and molecule groups
assert "auto-merge" in commands
assert "release" in commands
assert "publish" in commands
assert "setup" in commands
assert "install-tools" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands(DEVX_SRC_DIR)
assert isinstance(commands, list)
assert len(commands) > 0
def test_no_cli_file(self, tmp_path: Path) -> None:
"""Returns empty list when CLI file doesn't exist."""
commands = extract_cli_commands(tmp_path)
assert commands == []
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
"""When a command decorator has no explicit name, falls back to the def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
commands = extract_cli_commands(tmp_path)
assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
"""When a command decorator has no name and no following def, it is skipped."""
fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
)
commands = extract_cli_commands(tmp_path)
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands
assert "pass" not in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
content = "## auto-merge\n\nAuto-merge PR."
assert check_command_documented("auto-merge", content) is True
def test_finds_command_in_code_block(self) -> None:
content = "```bash\ndevx ci release --dry-run\n```"
assert check_command_documented("release", content) is True
def test_finds_command_with_devx_prefix(self) -> None:
content = "Use `devx tools setup` to install."
assert check_command_documented("setup", content) is True
def test_missing_command(self) -> None:
content = "## Other stuff\n\nNo commands here."
assert check_command_documented("release", content) is False
class TestCheckModuleDocumented:
def test_finds_module(self) -> None:
content = "The cli.py module handles..."
assert check_module_documented("cli.py", content) is True
def test_missing_module(self) -> None:
content = "No modules mentioned."
assert check_module_documented("cli.py", content) is False
class TestMain:
def test_all_present(self, tmp_path: Path) -> None:
"""When all docs exist and cover all commands/modules, exit 0."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
# Create a fake cli.py with some commands
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n"
"@cli.command('release')\ndef release():\n pass\n"
"@cli.command('setup')\ndef setup():\n pass\n"
)
# Create a fake module and CI script
(src / "config.py").write_text("# config module")
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
# Write cli-commands.md with all commands
cli_content = "## release\n\n## setup\n"
(docs / "user" / "cli-commands.md").write_text(cli_content)
# Write architecture.md with all modules
(docs / "tech" / "architecture.md").write_text("config.py")
# Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_missing_docs_fail(self, tmp_path: Path) -> None:
"""When docs are missing and --fail-on-missing is set, exit 1."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "MISSING" in result.output
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(scripts / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("")
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0