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