opencode-config/tests/test_spec_status.py
Sergey cc2c896290
fix(spec): spec-status parser quotes crlf bom (#232)
* fix(spec): strip quotes crlf bom in spec-status parser

* test(spec): cover quotes crlf bom trailing newline cases

* fix(ci): format test_spec_status.py write_bytes call

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 15:08:50 +03:00

852 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for .opencode/scripts/spec-status.py — spec phase oracle.
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. Filesystem checks (``docs/spec/*.md``) use ``tmp_path`` with
``monkeypatch`` on ``ss.SPEC_DIR`` and ``ss.META_FILE`` so each test sees
an isolated spec directory.
``get_repo_full_name`` is cached via ``functools.cache`` and called by
Phase 8 (``gh issue view --repo``). The ``_clear_repo_cache`` autouse
fixture clears the cache before and after each test; ``mock_run_cmd``
automatically includes a ``git remote get-url origin`` mock
(``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
boilerplate.
"""
import importlib.util
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "spec-status.py"
spec = importlib.util.spec_from_file_location("spec_status", SCRIPT_PATH)
ss = importlib.util.module_from_spec(spec)
sys.modules["spec_status"] = ss
spec.loader.exec_module(ss)
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 _clear_repo_cache():
"""Clear ``functools.cache`` on ``get_repo_full_name`` before/after each test."""
ss.get_repo_full_name.cache_clear()
yield
ss.get_repo_full_name.cache_clear()
def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
"""Factory: mock run_cmd matching by command prefix.
Keys are tuples of leading args (e.g. ("gh", "issue", "view")),
values are (returncode, stdout, stderr).
Automatically includes a ``git remote get-url origin`` mock
(``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
boilerplate in every test.
"""
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 _set_spec_dir(monkeypatch, tmp_path: Path) -> Path:
"""Point ``ss.SPEC_DIR`` and ``ss.META_FILE`` at ``tmp_path/spec``.
Creates the directory so tests can drop files into it. Returns the
spec dir path.
"""
spec_dir = tmp_path / "spec"
spec_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(ss, "SPEC_DIR", spec_dir)
monkeypatch.setattr(ss, "META_FILE", spec_dir / "meta.md")
return spec_dir
def _write_meta(spec_dir: Path, **fields) -> None:
"""Write a ``meta.md`` frontmatter file with the given key/value pairs."""
lines = ["---"]
for k, v in fields.items():
value = ("true" if v else "false") if isinstance(v, bool) else str(v)
lines.append(f"{k}: {value}")
lines.extend(["---", ""])
(spec_dir / "meta.md").write_text("\n".join(lines))
# ── parse_frontmatter ────────────────────────────────────────────────────────
def test_parse_frontmatter_empty():
assert ss.parse_frontmatter("") == {}
def test_parse_frontmatter_no_delimiters():
assert ss.parse_frontmatter("just text\nno frontmatter") == {}
def test_parse_frontmatter_basic():
content = "---\nproject: foo\ntype: backend\n---\nbody"
assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}
def test_parse_frontmatter_quoted_values():
content = '---\nproject: "foo bar"\ntype: "backend"\n---\n'
fm = ss.parse_frontmatter(content)
assert fm["project"] == "foo bar"
assert fm["type"] == "backend"
def test_parse_frontmatter_single_quoted_values():
content = "---\nproject: 'foo bar'\ntype: 'backend'\n---\n"
fm = ss.parse_frontmatter(content)
assert fm["project"] == "foo bar"
assert fm["type"] == "backend"
def test_parse_frontmatter_crlf():
"""CRLF line endings are supported (Windows-style meta.md)."""
content = "---\r\nproject: foo\r\ntype: backend\r\n---\r\n"
assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}
def test_parse_frontmatter_missing_trailing_newline():
"""Frontmatter without trailing newline (Write tool) is still parsed."""
content = "---\nproject: foo\ntype: backend\n---"
assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}
def test_parse_frontmatter_unbalanced_quote_preserved():
"""Unbalanced quotes are kept verbatim (no strip)."""
content = '---\nproject: "foo\n---\n'
assert ss.parse_frontmatter(content) == {"project": '"foo'}
def test_parse_frontmatter_multiline_body():
content = "---\nproject: foo\n---\nline1\nline2\nline3\n"
fm = ss.parse_frontmatter(content)
assert fm == {"project": "foo"}
# ── parse_remote_url ─────────────────────────────────────────────────────────
def test_parse_remote_url_https():
assert ss.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_https_no_git_suffix():
assert ss.parse_remote_url("https://github.com/slaid098/opencode-config") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_ssh():
assert ss.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_ssh_no_git_suffix():
assert ss.parse_remote_url("git@github.com:slaid098/opencode-config") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_invalid():
with pytest.raises(ValueError, match="Cannot parse remote URL"):
ss.parse_remote_url("not-a-valid-url")
def test_parse_remote_url_https_with_userinfo():
"""URL с git insteadOf userinfo (x-access-token:TOKEN@host) — host без userinfo.
Regression for PR#53/ADR-022: ``git config url.insteadOf`` rewrites
``https://github.com/`` to ``https://x-access-token:TOKEN@github.com/``,
so ``git remote get-url origin`` returns the rewritten URL. The old regex
greedily matched ``x-access-token:TOKEN@github.com`` as host, breaking
``get_repo_full_name`` (used in Phase 8 ``gh issue view --repo``).
"""
assert ss.parse_remote_url(
"https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git"
) == ("github.com", "slaid098", "opencode-config")
def test_parse_remote_url_https_without_userinfo():
"""Обычный HTTPS URL без userinfo работает (regression guard)."""
assert ss.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
# ── get_repo_full_name ───────────────────────────────────────────────────────
def test_get_repo_full_name_current_repo(monkeypatch):
ss.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ss,
"run_cmd",
mock_run_cmd(
{("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", "")}
),
)
assert ss.get_repo_full_name() == "slaid098/opencode-config"
ss.get_repo_full_name.cache_clear()
def test_get_repo_full_name_cached(monkeypatch):
ss.get_repo_full_name.cache_clear()
call_count = [0]
def _counting_mock(args: list[str]) -> tuple[int, str, str]:
if tuple(args[:2]) == ("git", "remote"):
call_count[0] += 1
return (0, "https://github.com/slaid098/opencode-config.git\n", "")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ss, "run_cmd", _counting_mock)
assert ss.get_repo_full_name() == "slaid098/opencode-config"
assert ss.get_repo_full_name() == "slaid098/opencode-config"
assert call_count[0] == 1
ss.get_repo_full_name.cache_clear()
def test_get_repo_full_name_remote_error(monkeypatch):
ss.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ss, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "not a git repository")})
)
with pytest.raises(RuntimeError, match="Cannot get git remote URL"):
ss.get_repo_full_name()
ss.get_repo_full_name.cache_clear()
def test_get_repo_full_name_with_userinfo(monkeypatch):
"""get_repo_full_name работает когда remote URL содержит userinfo.
Regression for PR#53/ADR-022: ``git remote get-url origin`` returns
``https://x-access-token:TOKEN@github.com/...`` after insteadOf. The
fix strips userinfo so ``get_repo_full_name()`` returns the correct
``org/repo`` (used in Phase 8 ``gh issue view --repo``).
"""
ss.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ss,
"run_cmd",
mock_run_cmd(
{
("git", "remote"): (
0,
"https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git\n",
"",
),
}
),
)
assert ss.get_repo_full_name() == "slaid098/opencode-config"
ss.get_repo_full_name.cache_clear()
# ── _resolve_repo_root ───────────────────────────────────────────────────────
def test_resolve_repo_root_via_git(monkeypatch, tmp_path):
fake_root = tmp_path / "some-repo"
fake_root.mkdir()
class _FakeResult:
returncode = 0
stdout = f"{fake_root}\n"
stderr = ""
def fake_run(args, **kwargs):
if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
return _FakeResult()
raise AssertionError(f"unmocked: {args}")
monkeypatch.setattr(ss.subprocess, "run", fake_run)
root = ss._resolve_repo_root()
assert root == fake_root.resolve()
def test_resolve_repo_root_fallback_to_file(monkeypatch):
class _FakeResult:
returncode = 1
stdout = ""
stderr = "not a git repo"
def fake_run(args, **kwargs):
if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
return _FakeResult()
raise AssertionError(f"unmocked: {args}")
monkeypatch.setattr(ss.subprocess, "run", fake_run)
root = ss._resolve_repo_root()
expected = Path(ss.__file__).resolve().parent.parent.parent
assert root == expected
# ── file helpers ─────────────────────────────────────────────────────────────
def test_file_filled_missing(tmp_path):
assert not ss.file_filled(tmp_path / "nope.md")
def test_file_filled_empty(tmp_path):
f = tmp_path / "empty.md"
f.write_text(" \n\n")
assert not ss.file_filled(f)
def test_file_filled_ok(tmp_path):
f = tmp_path / "ok.md"
f.write_text("content")
assert ss.file_filled(f)
def test_has_bullet_items_missing(tmp_path):
assert not ss.has_bullet_items(tmp_path / "nope.md")
def test_has_bullet_items_no_bullet(tmp_path):
f = tmp_path / "no_bullet.md"
f.write_text("just text\nno bullets here")
assert not ss.has_bullet_items(f)
def test_has_bullet_items_dash(tmp_path):
f = tmp_path / "dash.md"
f.write_text("- item one\n- item two\n")
assert ss.has_bullet_items(f)
def test_has_bullet_items_star(tmp_path):
f = tmp_path / "star.md"
f.write_text("* item one\n")
assert ss.has_bullet_items(f)
# ── read_meta (BOM + combined) ──────────────────────────────────────────────
def test_read_meta_strips_bom(monkeypatch, tmp_path):
"""BOM (utf-8-sig) is stripped so frontmatter parsing still works."""
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
meta = spec_dir / "meta.md"
meta.write_bytes(b"\xef\xbb\xbf---\nproject: foo\ntype: backend\n---\n")
_content, fm = ss.read_meta()
assert fm == {"project": "foo", "type": "backend"}
def test_read_meta_combined_bom_crlf_quotes(monkeypatch, tmp_path):
"""BOM + CRLF + quoted values parsed together (regression combo)."""
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
meta = spec_dir / "meta.md"
meta.write_bytes(b"\xef\xbb\xbf---\r\nproject: \"foo bar\"\r\ntype: 'backend'\r\n---\r\n")
_content, fm = ss.read_meta()
assert fm == {"project": "foo bar", "type": "backend"}
# ── check_detect (Phase 0) ───────────────────────────────────────────────────
def test_check_detect_no_meta(monkeypatch, tmp_path):
_set_spec_dir(monkeypatch, tmp_path)
# Ensure meta.md does not exist
assert not (tmp_path / "spec" / "meta.md").exists()
result = ss.check_detect()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "meta.md" in result.detail
def test_check_detect_empty_meta(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "meta.md").write_text("")
result = ss.check_detect()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_detect_no_frontmatter(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "meta.md").write_text("just some text\nwithout frontmatter\n")
result = ss.check_detect()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_detect_no_project_key(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, type="backend", created="2026-07-20")
result = ss.check_detect()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "project" in result.detail
def test_check_detect_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
result = ss.check_detect()
assert result.status == ss.PhaseStatus.DONE
# ── check_project_type (Phase 1) ─────────────────────────────────────────────
def test_check_project_type_empty_type(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="")
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_project_type_invalid_type(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="unknown-type")
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "unknown-type" in result.detail
def test_check_project_type_empty_project(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="", type="backend")
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_project_type_no_context_md(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
# No context.md
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "context.md" in result.detail
def test_check_project_type_empty_context_md(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "context.md").write_text(" \n")
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_project_type_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "context.md").write_text("A backend project for foo\n")
result = ss.check_project_type()
assert result.status == ss.PhaseStatus.DONE
assert "backend" in result.detail
# ── check_stack (Phase 2) ────────────────────────────────────────────────────
def test_check_stack_no_stack_md(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
result = ss.check_stack()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "stack.md" in result.detail
def test_check_stack_missing_items(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "stack.md").write_text("- only fastapi\n- nothing else\n")
result = ss.check_stack()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "tortoise" in result.detail
@pytest.mark.parametrize(
"ptype",
[
"backend",
"fullstack",
"mcp-server",
"cli",
"bot",
"worker",
],
)
def test_check_stack_ok_all_types(monkeypatch, tmp_path, ptype):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type=ptype)
required = ss.STACK_REQUIRED[ptype]
(spec_dir / "stack.md").write_text("\n".join(f"- {item}" for item in required) + "\n")
result = ss.check_stack()
assert result.status == ss.PhaseStatus.DONE, f"{ptype}: {result.detail}"
def test_check_stack_invalid_type(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="unknown")
result = ss.check_stack()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "невалиден" in result.detail
def test_check_stack_case_insensitive(monkeypatch, tmp_path):
"""STACK_REQUIRED substring match is case-insensitive."""
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "stack.md").write_text(
"- FastAPI\n- Tortoise ORM\n- UV\n- Pytest\n- Ruff\n- Mypy\n"
)
result = ss.check_stack()
assert result.status == ss.PhaseStatus.DONE
# ── check_modules (Phase 3) ──────────────────────────────────────────────────
def test_check_modules_no_file(monkeypatch, tmp_path):
_set_spec_dir(monkeypatch, tmp_path)
result = ss.check_modules()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_modules_empty(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "modules.md").write_text(" \n\n")
result = ss.check_modules()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_modules_no_bullet(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "modules.md").write_text("just prose\nno bullets")
result = ss.check_modules()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_modules_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "modules.md").write_text("- auth\n- users\n- posts\n")
result = ss.check_modules()
assert result.status == ss.PhaseStatus.DONE
assert "3" in result.detail
# ── check_db_schema (Phase 4) ────────────────────────────────────────────────
def test_check_db_schema_no_db_true(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="cli", no_db="true")
# db-schema.md intentionally absent
result = ss.check_db_schema()
assert result.status == ss.PhaseStatus.DONE
assert "no_db" in result.detail
def test_check_db_schema_no_file(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
result = ss.check_db_schema()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_db_schema_empty(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "db-schema.md").write_text(" \n\n")
result = ss.check_db_schema()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_db_schema_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
(spec_dir / "db-schema.md").write_text("User: id, name\nPost: id, title\n")
result = ss.check_db_schema()
assert result.status == ss.PhaseStatus.DONE
# ── check_infra (Phase 5) ────────────────────────────────────────────────────
def test_check_infra_no_file(monkeypatch, tmp_path):
_set_spec_dir(monkeypatch, tmp_path)
result = ss.check_infra()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_infra_empty(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "infra.md").write_text(" \n\n")
result = ss.check_infra()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_infra_short(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "infra.md").write_text("ab")
result = ss.check_infra()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "<3" in result.detail
def test_check_infra_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "infra.md").write_text("Docker compose, Prefect\n")
result = ss.check_infra()
assert result.status == ss.PhaseStatus.DONE
# ── check_roadmap (Phase 6) ──────────────────────────────────────────────────
def test_check_roadmap_no_file(monkeypatch, tmp_path):
_set_spec_dir(monkeypatch, tmp_path)
result = ss.check_roadmap()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_roadmap_empty(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "roadmap.md").write_text(" \n\n")
result = ss.check_roadmap()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_roadmap_no_bullet(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "roadmap.md").write_text("just prose\nno bullets")
result = ss.check_roadmap()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_roadmap_ok(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
(spec_dir / "roadmap.md").write_text("- scaffolding\n- auth module\n- users module\n")
result = ss.check_roadmap()
assert result.status == ss.PhaseStatus.DONE
assert "3" in result.detail
# ── check_confirm (Phase 7) ──────────────────────────────────────────────────
def test_check_confirm_true(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend", confirmed="true")
result = ss.check_confirm()
assert result.status == ss.PhaseStatus.DONE
def test_check_confirm_false(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend", confirmed="false")
result = ss.check_confirm()
assert result.status == ss.PhaseStatus.NOT_DONE
def test_check_confirm_missing_key(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend")
result = ss.check_confirm()
assert result.status == ss.PhaseStatus.NOT_DONE
# ── check_execute (Phase 8) ──────────────────────────────────────────────────
def _setup_execute_spec(spec_dir: Path, *, executed: str = "true") -> None:
"""Write a spec where Phases 0-7 are DONE and roadmap has #N references."""
_write_meta(
spec_dir,
project="foo",
type="backend",
confirmed="true",
executed=executed,
)
(spec_dir / "roadmap.md").write_text("- #1 scaffolding\n- #2 auth\n- #3 users\n")
def test_check_execute_not_executed(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend", executed="false")
result = ss.check_execute()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "executed" in result.detail
def test_check_execute_no_roadmap_file(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend", executed="true")
# No roadmap.md
result = ss.check_execute()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "roadmap.md" in result.detail
def test_check_execute_no_issue_refs(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_write_meta(spec_dir, project="foo", type="backend", executed="true")
(spec_dir / "roadmap.md").write_text("- scaffolding\n- auth\n") # No #N refs
result = ss.check_execute()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "#N" in result.detail
def test_check_execute_issues_found(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_setup_execute_spec(spec_dir)
monkeypatch.setattr(
ss,
"run_cmd",
mock_run_cmd({("gh", "issue", "view"): (0, "issue body", "")}),
)
result = ss.check_execute()
assert result.status == ss.PhaseStatus.DONE
assert "3" in result.detail
def test_check_execute_issues_missing(monkeypatch, tmp_path):
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_setup_execute_spec(spec_dir)
monkeypatch.setattr(
ss,
"run_cmd",
mock_run_cmd({("gh", "issue", "view"): (1, "", "not found")}),
)
result = ss.check_execute()
assert result.status == ss.PhaseStatus.NOT_DONE
assert "#1" in result.detail
assert "#2" in result.detail
assert "#3" in result.detail
def test_check_execute_no_remote(monkeypatch, tmp_path):
"""git remote fails → AMBIGUOUS."""
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
_setup_execute_spec(spec_dir)
ss.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ss,
"run_cmd",
mock_run_cmd({("git", "remote"): (1, "", "not a git repository")}),
)
result = ss.check_execute()
assert result.status == ss.PhaseStatus.AMBIGUOUS
assert "git remote error" in result.detail
ss.get_repo_full_name.cache_clear()
# ── find_current_phase ───────────────────────────────────────────────────────
def test_find_current_phase_all_done():
results = [ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 9
assert ss.find_current_phase(results) is None
def test_find_current_phase_first_not_done():
results = [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] + [
ss.PhaseResult(ss.PhaseStatus.DONE, "")
] * 8
assert ss.find_current_phase(results) == 0
def test_find_current_phase_middle():
results = (
[ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 3
+ [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")]
+ [ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 5
)
assert ss.find_current_phase(results) == 3
# ── format_output ────────────────────────────────────────────────────────────
def test_format_output_complete(monkeypatch):
results = [ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 9
fm = {"project": "test-project"}
monkeypatch.setattr("sys.argv", ["spec-status.py"])
output = ss.format_output(results, fm)
assert "Status: COMPLETE" in output
assert "Spec: test-project" in output
def test_format_output_next_action(monkeypatch):
results = (
[ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 3
+ [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "missing")]
+ [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 5
)
fm = {"project": "test-project"}
monkeypatch.setattr("sys.argv", ["spec-status.py"])
output = ss.format_output(results, fm)
assert "NEXT:" in output
assert "Phase 3" in output
def test_format_output_ambiguous(monkeypatch):
results = (
[ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 2
+ [ss.PhaseResult(ss.PhaseStatus.AMBIGUOUS, "remote error")]
+ [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 6
)
fm = {"project": "test-project"}
monkeypatch.setattr("sys.argv", ["spec-status.py"])
output = ss.format_output(results, fm)
assert "AMBIGUOUS" in output
assert "уточните статус вручную" in output
def test_format_output_unnamed_project(monkeypatch):
results = [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 9
fm = {}
monkeypatch.setattr("sys.argv", ["spec-status.py"])
output = ss.format_output(results, fm)
assert "Spec: <unnamed>" in output
def test_format_output_validate_shows_done_details(monkeypatch):
"""--validate flag shows full detail even for DONE phases."""
results = [ss.PhaseResult(ss.PhaseStatus.DONE, "specific detail")] * 9
fm = {"project": "test-project"}
monkeypatch.setattr("sys.argv", ["spec-status.py", "--validate"])
output = ss.format_output(results, fm)
# In non-validate mode DONE phases show "done" only; --validate shows detail.
assert "specific detail" in output
# ── main ─────────────────────────────────────────────────────────────────────
def test_main_no_spec(monkeypatch, tmp_path, capsys):
"""Running main() with no docs/spec/ → 9 ❌ lines + NEXT, exit 0."""
_set_spec_dir(monkeypatch, tmp_path)
monkeypatch.setattr("sys.argv", ["spec-status.py"])
ss.main()
captured = capsys.readouterr()
assert "Spec: <unnamed>" in captured.out
assert "NEXT:" in captured.out
assert "DETECT" in captured.out