"""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