"""Tests for .opencode/scripts/project-status.py — architecture oracle. All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` helper. Filesystem checks use ``tmp_path`` with ``monkeypatch`` on ``ps.REPO_ROOT`` so each test sees an isolated repo. Covers: auto-detect (5 types + unknown), 7 check groups, format output ([OK]/[WARN]/[FAIL] + Итог + Рекомендации), exit codes (non-blocking vs ``--check`` strict), ``--fast`` skip of branch protection. """ import importlib.util import sys from pathlib import Path import pytest SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py" spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH) ps = importlib.util.module_from_spec(spec) sys.modules["project_status"] = ps spec.loader.exec_module(ps) GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( ("git", "remote"), (0, "https://github.com/slaid098/opencode-config.git\n", ""), ) @pytest.fixture(autouse=True) def _isolate_repo(monkeypatch, tmp_path): """Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test.""" monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) monkeypatch.setattr(ps, "CONFIG", dict(ps.DEFAULT_CONFIG)) def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]): """Factory: mock run_cmd matching by command prefix. Automatically includes a ``git remote get-url origin`` mock so ``get_repo_full_name()`` works without extra boilerplate. """ merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses} def _mock(args: list[str]) -> tuple[int, str, str]: for prefix, result in merged.items(): if tuple(args[: len(prefix)]) == tuple(prefix): return result return (1, "", f"unmocked call: {args}") return _mock def _tool_sections( has_ruff: bool, has_mypy: bool, has_pytest: bool, cov_source: list | None, cov_fail: str | None, ) -> list[str]: """Build the [tool.*] section lines for pyproject.toml.""" lines: list[str] = [] if has_ruff: lines.extend(["[tool.ruff]", 'target-version = "py312"', ""]) if has_mypy: lines.extend(["[tool.mypy]", 'python_version = "3.12"', ""]) if has_pytest: lines.append("[tool.pytest.ini_options]") addopts = "--cov=src --cov-report=term-missing --timeout=120" if cov_fail: addopts += f" --cov-fail-under={cov_fail}" lines.append(f'addopts = "{addopts}"') lines.extend(['testpaths = ["tests"]', ""]) if cov_source is not None: lines.append("[tool.coverage.run]") source_str = ", ".join(f'"{s}"' for s in cov_source) lines.append(f"source = [{source_str}]") lines.append("") return lines def _write_pyproject( tmp_path: Path, *, deps: list[str] | None = None, scripts: dict | None = None, has_ruff: bool = True, has_mypy: bool = True, has_pytest: bool = True, cov_source: list | None = None, cov_fail: str | None = None, ) -> None: """Write a minimal pyproject.toml with the requested [tool.*] sections.""" lines = [ "[build-system]", 'requires = ["hatchling"]', 'build-backend = "hatchling.build"', "", "[project]", 'name = "test-repo"', 'version = "0.1.0"', "", "dependencies = [", ] for d in deps or []: lines.append(f' "{d}",') lines.extend(["]", "", "[project.optional-dependencies]", "dev = ["]) if has_pytest: lines.append(' "pytest>=8.0",') if has_mypy: lines.append(' "mypy>=1.10",') if has_ruff: lines.append(' "ruff>=0.5",') lines.extend(["]", ""]) if scripts: lines.append("[project.scripts]") for k, v in scripts.items(): lines.append(f'{k} = "{v}"') lines.append("") lines.extend(_tool_sections(has_ruff, has_mypy, has_pytest, cov_source, cov_fail)) (tmp_path / "pyproject.toml").write_text("\n".join(lines)) def _make_backend_repo(tmp_path: Path) -> None: """Create a minimal backend-type repo skeleton in tmp_path.""" for rel in [ "src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config", "tests", ]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "main.py").write_text( "from contextlib import asynccontextmanager\n" "@asynccontextmanager\nasync def lifespan(app): yield\n" ) (tmp_path / "src/api/v1/users.py").write_text( "from fastapi import APIRouter\nrouter = APIRouter()\n" "@router.get('/users')\nasync def list_users():\n return []\n" ) _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"], cov_fail="80") (tmp_path / "tests/conftest.py").write_text("import pytest\n") (tmp_path / "tests/test_users.py").write_text("def test_ok(): assert True\n") # ── parse_remote_url ───────────────────────────────────────────────────────── def test_parse_remote_url_https(): assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == ( "github.com", "slaid098", "opencode-config", ) def test_parse_remote_url_ssh(): assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == ( "github.com", "slaid098", "opencode-config", ) def test_parse_remote_url_invalid(): with pytest.raises(ValueError, match="Cannot parse remote URL"): ps.parse_remote_url("not-a-valid-url") # ── get_repo_full_name ─────────────────────────────────────────────────────── def test_get_repo_full_name_ok(monkeypatch): monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({})) assert ps.get_repo_full_name() == "slaid098/opencode-config" def test_get_repo_full_name_no_remote(monkeypatch): monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "no remote")})) assert ps.get_repo_full_name() is None def test_get_repo_full_name_bad_url(monkeypatch): monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (0, "not-a-url\n", "")})) assert ps.get_repo_full_name() is None # ── load_config ────────────────────────────────────────────────────────────── def test_load_config_defaults_when_no_pyproject(tmp_path): monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) cfg = ps.load_config() assert cfg["route_line_limit"] == 50 assert cfg["min_test_count"] == 1 def test_load_config_reads_section(tmp_path): monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) (tmp_path / "pyproject.toml").write_text( "[tool.project-status]\nroute_line_limit = 80\nmin_test_count = 3\n" ) cfg = ps.load_config() assert cfg["route_line_limit"] == 80 assert cfg["min_test_count"] == 3 assert cfg["require_branch_protection"] is False def test_load_config_bad_toml_returns_defaults(tmp_path): monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) (tmp_path / "pyproject.toml").write_text("not valid toml = = =") cfg = ps.load_config() assert cfg["route_line_limit"] == 50 # ── detect_project_type ────────────────────────────────────────────────────── def test_detect_fullstack(tmp_path): (tmp_path / "backend").mkdir() (tmp_path / "frontend").mkdir() assert ps.detect_project_type() == ps.ProjectType.FULLSTACK def test_detect_backend(tmp_path): _make_backend_repo(tmp_path) assert ps.detect_project_type() == ps.ProjectType.BACKEND def test_detect_bot_via_file(tmp_path): (tmp_path / "src").mkdir() (tmp_path / "src/bot.py").write_text("from aiogram import Dispatcher\n") _write_pyproject(tmp_path, deps=[]) assert ps.detect_project_type() == ps.ProjectType.BOT def test_detect_bot_via_dep(tmp_path): _write_pyproject(tmp_path, deps=["aiogram"]) assert ps.detect_project_type() == ps.ProjectType.BOT def test_detect_worker_via_file(tmp_path): (tmp_path / "src").mkdir() (tmp_path / "src/flow.py").write_text("from prefect import flow\n") _write_pyproject(tmp_path, deps=[]) assert ps.detect_project_type() == ps.ProjectType.WORKER def test_detect_worker_via_dep(tmp_path): _write_pyproject(tmp_path, deps=["prefect"]) assert ps.detect_project_type() == ps.ProjectType.WORKER def test_detect_cli(tmp_path): (tmp_path / "src").mkdir() pkg = tmp_path / "src" / "mycli" pkg.mkdir() (pkg / "__init__.py").write_text("") _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}) assert ps.detect_project_type() == ps.ProjectType.CLI def test_detect_unknown_empty_repo(tmp_path): _write_pyproject(tmp_path, deps=[]) assert ps.detect_project_type() == ps.ProjectType.UNKNOWN # ── check_structure ────────────────────────────────────────────────────────── def test_check_structure_backend_ok(tmp_path): _make_backend_repo(tmp_path) group = ps.check_structure(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK assert all(c.status == ps.CheckStatus.OK for c in group.checks) def test_check_structure_backend_missing_dir(tmp_path): _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) (tmp_path / "main.py").write_text( "from contextlib import asynccontextmanager\n" "@asynccontextmanager\nasync def lifespan(app): yield\n" ) group = ps.check_structure(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.FAIL assert any(c.name == "src/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks) def test_check_structure_backend_no_lifespan(tmp_path): for rel in [ "src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config", "tests", ]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "main.py").write_text("app = None\n") group = ps.check_structure(ps.ProjectType.BACKEND) assert any( c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks ) def test_check_structure_unknown_warn(tmp_path): group = ps.check_structure(ps.ProjectType.UNKNOWN) assert group.overall() == ps.CheckStatus.WARN def test_check_structure_cli_with_package(tmp_path): (tmp_path / "src").mkdir() pkg = tmp_path / "src" / "mycli" pkg.mkdir() (pkg / "__init__.py").write_text("") group = ps.check_structure(ps.ProjectType.CLI) assert any(c.status == ps.CheckStatus.OK and "package" in c.name for c in group.checks) def test_check_structure_cli_no_package(tmp_path): (tmp_path / "src").mkdir() group = ps.check_structure(ps.ProjectType.CLI) assert any(c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_thin_routes ──────────────────────────────────────────────────────── def test_thin_routes_ok(tmp_path): _make_backend_repo(tmp_path) group = ps.check_thin_routes(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK def test_thin_routes_over_limit(tmp_path): for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "main.py").write_text("app = None\n") body = "\n x = 1\n" * 60 (tmp_path / "src/api/v1/users.py").write_text( "from fastapi import APIRouter\nrouter = APIRouter()\n" "@router.get('/users')\nasync def list_users():" f"{body} return []\n" ) group = ps.check_thin_routes(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.FAIL assert any("превышение" in c.detail for c in group.checks) def test_thin_routes_not_applicable_for_cli(tmp_path): group = ps.check_thin_routes(ps.ProjectType.CLI) assert group.overall() == ps.CheckStatus.OK def test_thin_routes_no_api_dir(tmp_path): group = ps.check_thin_routes(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.WARN # ── check_quality ──────────────────────────────────────────────────────────── def test_quality_ok(tmp_path): _make_backend_repo(tmp_path) group = ps.check_quality(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK def test_quality_missing_mypy(tmp_path): _write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False) group = ps.check_quality(ps.ProjectType.BACKEND) assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks) def test_quality_missing_ruff(tmp_path): _write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False) group = ps.check_quality(ps.ProjectType.BACKEND) assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_tests ────────────────────────────────────────────────────────────── def test_tests_ok(tmp_path): _make_backend_repo(tmp_path) group = ps.check_tests(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK def test_tests_no_dir(tmp_path): group = ps.check_tests(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.FAIL assert any("tests/" in c.name for c in group.checks) def test_tests_no_conftest(tmp_path): (tmp_path / "tests").mkdir() (tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n") group = ps.check_tests(ps.ProjectType.BACKEND) assert any(c.name == "conftest.py" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_tests_asyncio_mark_warns(tmp_path): (tmp_path / "tests").mkdir() (tmp_path / "tests/test_x.py").write_text( "import pytest\n@pytest.mark.asyncio\nasync def test_x(): assert True\n" ) group = ps.check_tests(ps.ProjectType.BACKEND) assert any( c.name == "no @pytest.mark.asyncio" and c.status == ps.CheckStatus.WARN for c in group.checks ) # ── check_readme ───────────────────────────────────────────────────────────── def _write_valid_readme(tmp_path: Path) -> None: content = ( "# 🚀 Title\n[English](#-english) [Русский](#-русский)\n" "## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n" ) for d in ps.README_DELIMITERS: content += f"\n" (tmp_path / "README.md").write_text(content) def test_readme_ok(tmp_path): _write_valid_readme(tmp_path) group = ps.check_readme(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK def test_readme_missing(tmp_path): group = ps.check_readme(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.FAIL def test_readme_missing_delimiters(tmp_path): (tmp_path / "README.md").write_text( "# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n" ) group = ps.check_readme(ps.ProjectType.BACKEND) assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_infra ────────────────────────────────────────────────────────────── def test_infra_ok_with_branch_protection(monkeypatch, tmp_path): for rel in [".github/workflows", ".github"]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") (tmp_path / ".github/dependabot.yml").write_text("version: 2\n") (tmp_path / "LICENSE").write_text("MIT\n") (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "api"): ( 0, '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', "", ), } ), ) group = ps.check_infra(ps.ProjectType.BACKEND, fast=False) assert group.overall() == ps.CheckStatus.OK def test_infra_missing_ci_fail(tmp_path): (tmp_path / "LICENSE").write_text("MIT\n") group = ps.check_infra(ps.ProjectType.BACKEND, fast=True) assert any( c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL for c in group.checks ) def test_infra_fast_skips_branch_protection(tmp_path): (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True) (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") (tmp_path / "LICENSE").write_text("MIT\n") group = ps.check_infra(ps.ProjectType.BACKEND, fast=True) assert any( c.name == "branch protection" and c.status == ps.CheckStatus.WARN and "--fast" in c.detail for c in group.checks ) def test_infra_branch_protection_gh_error(monkeypatch, tmp_path): (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True) (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") (tmp_path / "LICENSE").write_text("MIT\n") monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "not found")})) group = ps.check_infra(ps.ProjectType.BACKEND, fast=False) assert any( c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks ) # ── check_coverage ─────────────────────────────────────────────────────────── def test_coverage_non_blocking(tmp_path): _write_pyproject(tmp_path, deps=["fastapi"], cov_source=["src"], cov_fail="80") group = ps.check_coverage(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.OK assert all(c.status != ps.CheckStatus.FAIL for c in group.checks) def test_coverage_no_config(tmp_path): _write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False) group = ps.check_coverage(ps.ProjectType.BACKEND) assert group.overall() == ps.CheckStatus.WARN assert all(c.status != ps.CheckStatus.FAIL for c in group.checks) # ── format_output ──────────────────────────────────────────────────────────── def test_format_output_has_summary_and_recommendations(): ptype = ps.ProjectType.BACKEND groups = [ ps.GroupResult( name="Структура", checks=[ ps.CheckResult(ps.CheckStatus.OK, "src/api/v1", "ok"), ps.CheckResult(ps.CheckStatus.FAIL, "main.py", "missing"), ], ), ps.GroupResult(name="Coverage", checks=[ps.CheckResult(ps.CheckStatus.OK, "cov", "ok")]), ] out = ps.format_output(ptype, groups) assert "Project: backend" in out assert "[OK] Структура" not in out assert "[FAIL] Структура" in out assert "Итог:" in out assert "Рекомендации:" in out assert "main.py" in out def test_format_output_no_recommendations_when_all_ok(): groups = [ ps.GroupResult(name="Структура", checks=[ps.CheckResult(ps.CheckStatus.OK, "x", "ok")]), ] out = ps.format_output(ps.ProjectType.BACKEND, groups) assert "Рекомендации:" not in out assert "FAIL: 0" in out # ── main / exit codes ──────────────────────────────────────────────────────── def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys): _make_backend_repo(tmp_path) monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")})) monkeypatch.setattr("sys.argv", ["project-status.py"]) with pytest.raises(SystemExit) as exc: ps.main() assert exc.value.code == 0 captured = capsys.readouterr() assert "Project:" in captured.out assert "Итог:" in captured.out def test_main_check_strict_exit_1_on_fail(monkeypatch, tmp_path, capsys): monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")})) monkeypatch.setattr("sys.argv", ["project-status.py", "--check"]) with pytest.raises(SystemExit) as exc: ps.main() assert exc.value.code == 1 def test_main_check_strict_exit_0_when_all_ok(monkeypatch, tmp_path, capsys): _make_backend_repo(tmp_path) for rel in [".github/workflows", ".github"]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") (tmp_path / ".github/dependabot.yml").write_text("version: 2\n") (tmp_path / "LICENSE").write_text("MIT\n") (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") _write_valid_readme(tmp_path) monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "api"): ( 0, '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', "", ), } ), ) monkeypatch.setattr("sys.argv", ["project-status.py", "--check"]) with pytest.raises(SystemExit) as exc: ps.main() captured = capsys.readouterr() assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}\n{captured.out}" def test_main_fast_flag(monkeypatch, tmp_path, capsys): _make_backend_repo(tmp_path) monkeypatch.setattr( ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "should not be called")}) ) monkeypatch.setattr("sys.argv", ["project-status.py", "--fast"]) with pytest.raises(SystemExit) as exc: ps.main() assert exc.value.code == 0 captured = capsys.readouterr() assert "--fast" in captured.out # ── run_all_checks integration ─────────────────────────────────────────────── def test_run_all_checks_returns_7_groups(tmp_path): _make_backend_repo(tmp_path) groups = ps.run_all_checks(ps.ProjectType.BACKEND, fast=True) assert len(groups) == 7 assert [g.name for g in groups] == ps.CHECK_GROUPS