✨ feat(playbook): add plan progress tracking and rules updates
This commit is contained in:
@@ -9,6 +9,12 @@ tests/
|
||||
├── README.md # 本文件:测试文档
|
||||
├── cli/ # Python CLI 测试(unittest)
|
||||
│ └── test_playbook_cli.py # playbook.py 基础功能测试
|
||||
├── test_format_md_action.py # format_md 动作测试
|
||||
├── test_gitattributes_modes.py # gitattr_mode 行为测试
|
||||
├── test_plan_progress_cli.py # plan_progress CLI 测试
|
||||
├── test_superpowers_list_sync.py # superpowers 列表一致性测试
|
||||
├── test_sync_templates_placeholders.py # 占位符替换测试
|
||||
├── test_toml_edge_cases.py # TOML 解析边界测试
|
||||
├── templates/ # 模板验证测试
|
||||
│ ├── validate_python_templates.sh # Python 模板验证
|
||||
│ ├── validate_cpp_templates.sh # C++ 模板验证
|
||||
@@ -27,6 +33,9 @@ cd /path/to/playbook
|
||||
# 1. 运行 Python CLI 测试
|
||||
python -m unittest discover -s tests/cli -v
|
||||
|
||||
# 1.1 运行其他 Python 测试(tests/ 下的 test_*.py)
|
||||
python -m unittest discover -s tests -p "test_*.py" -v
|
||||
|
||||
# 2. 运行模板验证测试
|
||||
sh tests/templates/validate_python_templates.sh
|
||||
sh tests/templates/validate_cpp_templates.sh
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
|
||||
|
||||
def run_cli(*args, env=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
class FormatMdActionTests(unittest.TestCase):
|
||||
def test_format_md_invokes_prettier_from_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
(root / "README.md").write_text("# Title\n", encoding="utf-8")
|
||||
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
prettier = bin_dir / "prettier"
|
||||
prettier.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(\".prettier_called\").write_text(\"ok\")\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
prettier.chmod(0o755)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{tmp_dir}\"
|
||||
|
||||
[format_md]
|
||||
# tool defaults to prettier
|
||||
# keep default globs
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{bin_dir}:{env.get('PATH', '')}"
|
||||
|
||||
result = run_cli("-config", str(config_path), env=env)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertTrue((root / ".prettier_called").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
SOURCE_GITATTR = ROOT / ".gitattributes"
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def read_entries(path: Path) -> list[str]:
|
||||
entries = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
entries.append(stripped)
|
||||
return entries
|
||||
|
||||
|
||||
class GitattributesModeTests(unittest.TestCase):
|
||||
def _run_sync(self, root: Path, mode: str) -> subprocess.CompletedProcess:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{root}\"
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"tsl\"]
|
||||
gitattr_mode = \"{mode}\"
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
return run_cli("-config", str(config_path), cwd=root)
|
||||
|
||||
def test_gitattr_mode_skip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
sentinel = "*.keep text eol=lf\n"
|
||||
(root / ".gitattributes").write_text(sentinel, encoding="utf-8")
|
||||
|
||||
result = self._run_sync(root, "skip")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(
|
||||
(root / ".gitattributes").read_text(encoding="utf-8"),
|
||||
sentinel,
|
||||
)
|
||||
|
||||
def test_gitattr_mode_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
(root / ".gitattributes").write_text("bad\n", encoding="utf-8")
|
||||
|
||||
result = self._run_sync(root, "overwrite")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(
|
||||
(root / ".gitattributes").read_text(encoding="utf-8"),
|
||||
SOURCE_GITATTR.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_gitattr_mode_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
result = self._run_sync(root, "block")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
content = (root / ".gitattributes").read_text(encoding="utf-8")
|
||||
self.assertIn("# BEGIN playbook .gitattributes", content)
|
||||
self.assertIn("# END playbook .gitattributes", content)
|
||||
|
||||
def test_gitattr_mode_append(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
src_entries = read_entries(SOURCE_GITATTR)
|
||||
(root / ".gitattributes").write_text(
|
||||
src_entries[0] + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
result = self._run_sync(root, "append")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
content = (root / ".gitattributes").read_text(encoding="utf-8")
|
||||
self.assertIn("Added from playbook .gitattributes", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "plan_progress.py"
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
class PlanProgressCliTests(unittest.TestCase):
|
||||
def test_select_prefers_in_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-old.md").write_text("old", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-new.md").write_text("new", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"[PLAN] docs/plans/2026-01-01-old.md | status=in-progress | date=2026-01-03\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"select",
|
||||
"-plans",
|
||||
"docs/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-01-old.md")
|
||||
|
||||
def test_select_skips_done_and_blocked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-a.md").write_text("a", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-b.md").write_text("b", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[PLAN] docs/plans/2026-01-02-b.md | status=done | date=2026-01-03",
|
||||
"[PLAN] docs/plans/2026-01-01-a.md | status=blocked | date=2026-01-03",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"select",
|
||||
"-plans",
|
||||
"docs/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("no pending plans", (result.stdout + result.stderr).lower())
|
||||
|
||||
def test_record_creates_section(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-plan",
|
||||
"docs/plans/2026-01-03-demo.md",
|
||||
"-status",
|
||||
"done",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-note",
|
||||
"done",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("## Plan 状态记录", text)
|
||||
self.assertIn("status=done", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS_MD = ROOT / "SKILLS.md"
|
||||
SOURCES_LIST = ROOT / "codex" / "skills" / ".sources" / "superpowers.list"
|
||||
|
||||
|
||||
def read_sources_list() -> list[str]:
|
||||
return [
|
||||
line.strip()
|
||||
for line in SOURCES_LIST.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
|
||||
|
||||
def read_skills_md_list() -> list[str]:
|
||||
lines = SKILLS_MD.read_text(encoding="utf-8").splitlines()
|
||||
start = "<!-- superpowers:skills:start -->"
|
||||
end = "<!-- superpowers:skills:end -->"
|
||||
try:
|
||||
start_idx = lines.index(start) + 1
|
||||
end_idx = lines.index(end)
|
||||
except ValueError as exc:
|
||||
raise AssertionError("superpowers markers missing in SKILLS.md") from exc
|
||||
|
||||
items = []
|
||||
for line in lines[start_idx:end_idx]:
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("-"):
|
||||
continue
|
||||
items.append(stripped.lstrip("- ").strip())
|
||||
return items
|
||||
|
||||
|
||||
class SuperpowersListSyncTests(unittest.TestCase):
|
||||
def test_superpowers_list_matches_skills_md(self):
|
||||
self.assertEqual(read_sources_list(), read_skills_md_list())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,51 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class SyncTemplatesPlaceholdersTests(unittest.TestCase):
|
||||
def test_main_language_placeholder_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{tmp_dir}\"
|
||||
|
||||
[sync_templates]
|
||||
project_name = \"Demo\"
|
||||
full = true
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"cpp\", \"tsl\"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
agents_md = Path(tmp_dir) / "AGENTS.md"
|
||||
text = agents_md.read_text(encoding="utf-8")
|
||||
self.assertIn(".agents/cpp/index.md", text)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", text)
|
||||
|
||||
rules_md = Path(tmp_dir) / "AGENT_RULES.md"
|
||||
rules_text = rules_md.read_text(encoding="utf-8")
|
||||
self.assertIn("docs/standards/playbook/scripts/plan_progress.py", rules_text)
|
||||
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
import unittest
|
||||
|
||||
from scripts import playbook
|
||||
|
||||
|
||||
class TomlEdgeCaseTests(unittest.TestCase):
|
||||
def test_minimal_parser_allows_dotted_section_name(self):
|
||||
raw = """
|
||||
[a.b]
|
||||
key = 1
|
||||
"""
|
||||
data = playbook.loads_toml_minimal(raw)
|
||||
self.assertIn("a.b", data)
|
||||
self.assertEqual(data["a.b"]["key"], 1)
|
||||
|
||||
def test_minimal_parser_rejects_multiline_string(self):
|
||||
raw = '[section]\nvalue = """line1\nline2"""\n'
|
||||
with self.assertRaises(ValueError):
|
||||
playbook.loads_toml_minimal(raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user