Files
devx/tests/unit/test_doc_coverage.py
T
emil ef08513bcf
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
DEVX-122: feat: consolidate docs checks into devx-docs-check target
2026-07-07 22:00:07 +00:00

398 lines
19 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
def test_command_with_explicit_name_param(self, tmp_path: Path) -> None:
"""When a command uses name="explicit-name", that name is extracted."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
'@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "my-command" in commands
assert "my_command" not in commands
def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help= kwarg but no name=, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help="Do something useful")\ndef do_something():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "do_something" in commands
assert "Do something useful" not in commands
def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help=_() translation, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "install" in commands
assert "Install and configure things" 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
def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir is empty string, CI script checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "__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")
(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), "--source-dir", str(src), "--ci-scripts-dir", ""])
assert result.exit_code == 0
assert "100%" in result.output
# Should not mention any CI scripts
assert "MISSING" not in result.output or "CI script" not in result.output
def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a directory, scripts are detected from there."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
ci_dir = tmp_path / "ci"
ci_dir.mkdir()
(ci_dir / "my_script.py").write_text("# my script")
(ci_dir / "__init__.py").write_text("")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)]
)
assert result.exit_code == 0
assert "my_script.py" in result.output
assert "OK: my_script.py" in result.output
def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a non-existent path, CI checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"]
)
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__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")
(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("")
# Write pyproject.toml with ci_scripts_dir = ""
(tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n')
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_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
custom_docs = tmp_path / "custom-docs"
(custom_docs / "user").mkdir(parents=True)
(custom_docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__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")
(custom_docs / "user" / "cli-commands.md").write_text("## release\n")
(custom_docs / "tech" / "architecture.md").write_text("config.py")
(custom_docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with custom docs_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
custom_src = tmp_path / "custom-src" / "myapp"
custom_src.mkdir(parents=True)
(custom_src / "__init__.py").write_text("")
(custom_src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(custom_src / "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("")
# Write pyproject.toml with custom source_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] is not a dict, falls back to defaults."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__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")
(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("")
# Write pyproject.toml with doc_coverage as a non-dict value
(tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n')
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output