Files
.pouch/tests/test_select.py
T
laily f3cd56b78e feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container.
Keep the inner skills/ packages, and store ACK project state in
.pouch/ack instead of docs/ack.
2026-08-25 15:20:02 +08:00

623 lines
23 KiB
Python

from __future__ import annotations
import argparse
import curses
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from pouch import cli
from pouch import yaml_io
from pouch.catalog import catalog_repo_path, catalog_skill_path
from pouch.selector import SkillChoice, filter_choices, fit_to_width, select_skills
REPO_ROOT = Path(__file__).resolve().parents[1]
class SelectorTests(unittest.TestCase):
def test_fit_to_width_counts_cjk_as_two_columns(self) -> None:
self.assertEqual(fit_to_width("a中文b", 4), "a中")
self.assertEqual(fit_to_width("初始化 ACK", 8), "初始化 A")
def test_manifest_entry_targets_round_trip(self) -> None:
data = {
"skills": [
{
"name": "catalog-one",
"source": "registry",
"targets": ["codex"],
}
]
}
self.assertEqual(yaml_io.safe_load(yaml_io.safe_dump(data)), data)
def test_filter_matches_name_kind_and_description(self) -> None:
choices = [
SkillChoice("frontend-design", "catalog", "创建界面"),
SkillChoice(
"discussion-notes",
"builtin",
"维护讨论笔记",
readonly_status="全局: codex",
),
]
self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"])
self.assertEqual([c.name for c in filter_choices(choices, "builtin")], ["discussion-notes"])
self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"])
self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"])
def test_selector_preserves_preselected_items(self) -> None:
choices = [
SkillChoice("already-there", "builtin", installed=True),
SkillChoice("new-skill", "catalog"),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
selected = select_skills(
choices,
wrapper=lambda draw: draw(screen),
)
self.assertEqual(selected, {"already-there"})
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertTrue(any("取消选择不卸载" in line for line in rendered))
def test_selector_renders_scope_and_readonly_global_status(self) -> None:
choices = [
SkillChoice(
"ack",
"builtin",
installed=False,
readonly_status="全局: cursor,claude,codex,agents",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 120)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
selected = select_skills(
choices,
scope_label="项目 /code/app(全局状态只读)",
wrapper=lambda draw: draw(screen),
)
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertEqual(selected, set())
self.assertTrue(any("安装目标: 项目 /code/app" in line for line in rendered))
title = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[ ] ack", title)
self.assertIn("全局: cursor,claude,codex,agents", title)
self.assertNotIn("初始化", title)
def test_selector_renders_description_on_indented_second_line(self) -> None:
choices = [
SkillChoice(
"ack",
"builtin",
description="初始化、检查并运行 ACK",
readonly_status="全局: codex",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
title_calls = [
call for call in screen.addnstr.call_args_list if call.args[0] == 3
]
description_call = next(
call for call in screen.addnstr.call_args_list if call.args[0] == 4
)
self.assertEqual(
"".join(call.args[2] for call in title_calls),
"[ ] ack builtin 全局: codex",
)
self.assertEqual(description_call.args[2], " 初始化、检查并运行 ACK")
name_call = next(call for call in title_calls if call.args[2] == "ack")
self.assertTrue(name_call.args[4] & curses.A_BOLD)
self.assertTrue(description_call.args[4] & curses.A_DIM)
def test_repository_choice_toggles_all_child_skills(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "catalog:waza", indent=1),
SkillChoice("waza/ui", "catalog:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.side_effect = [" ", "\n"]
with patch.object(curses, "curs_set"):
selected = select_skills(choices, wrapper=lambda draw: draw(screen))
self.assertEqual(selected, {"waza/think", "waza/ui"})
def test_repository_choice_renders_partial_selection(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "catalog:waza", installed=True, indent=1),
SkillChoice("waza/ui", "catalog:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
first_row = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[-] waza", first_row)
def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"}
self.assertEqual(catalog_repo_path(first), catalog_repo_path(second))
def test_catalog_skill_path_rejects_checkout_escape(self) -> None:
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "../../outside",
}
with self.assertRaisesRegex(SystemExit, "超出来源仓库"):
catalog_skill_path("unsafe-skill", entry)
def test_collection_discovery_ignores_symlinked_skill(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
checkout = root / "checkout"
collection = checkout / "skills"
outside = root / "outside"
collection.mkdir(parents=True)
outside.mkdir()
outside.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
collection.joinpath("escaped").symlink_to(outside, target_is_directory=True)
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "skills",
}
with patch("pouch.catalog.catalog_checkout_path", return_value=checkout):
from pouch.catalog import discover_catalog_skills
self.assertEqual(discover_catalog_skills("unsafe", entry), {})
def test_catalog_clone_is_shallow(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
checkout = root / "checkout"
skill_root = checkout / "skills"
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "skills",
}
with (
patch.object(cli, "catalog_skill_path", return_value=skill_root),
patch.object(cli, "catalog_checkout_path", return_value=checkout),
patch.object(
cli,
"discover_catalog_skills",
side_effect=[{}, {"demo": skill_root / "demo"}],
),
patch.object(cli.subprocess, "run") as run,
):
cli._ensure_catalog_fetched("demo-pack", entry)
run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"main",
"--",
"https://example.test/skills.git",
str(checkout),
],
check=True,
)
class SelectCommandTests(unittest.TestCase):
def test_non_tty_exits_with_add_guidance(self) -> None:
with tempfile.TemporaryDirectory() as temp:
home = Path(temp)
(home / ".pouch" / "skills").mkdir(parents=True)
env = os.environ.copy()
env["HOME"] = str(home)
env["PYTHONPATH"] = str(REPO_ROOT)
result = subprocess.run(
[sys.executable, "-m", "pouch", "select"],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("pouch add <name>", result.stderr)
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
catalog = project / "catalog-one"
catalog.mkdir()
catalog.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
args = argparse.Namespace(
agents=[["codex"]],
global_scope=False,
project=str(project),
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
installed: list[str] = []
selected_choices: list[SkillChoice] = []
selected_scope: list[str] = []
def choose(
choices: list[SkillChoice],
*,
scope_label: str,
) -> set[str]:
selected_choices.extend(choices)
selected_scope.append(scope_label)
return {"builtin-one", "catalog-one"}
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_pouch_home"),
patch.object(cli, "list_builtin_skills", return_value=["builtin-one"]),
patch.object(cli, "skill_description", return_value="builtin"),
patch.object(
cli,
"load_catalog",
return_value={
"catalog-one": {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "catalog-one",
}
},
),
patch.object(cli, "_catalog_skill_names", return_value=["catalog-one"]),
patch.object(cli, "catalog_skill_path", return_value=catalog),
patch.object(cli, "_list_fully_installed_names", return_value=["builtin-one"]),
patch.object(
cli,
"_global_installation_note",
return_value="全局: codex",
),
patch.object(
cli,
"_is_fully_installed",
side_effect=lambda name, expected, project_root, targets: name == "builtin-one",
),
patch.object(
cli,
"select_skills",
side_effect=choose,
),
patch.object(
cli,
"_install_skill",
side_effect=lambda name, targets, project_root, **kwargs: installed.append(name),
),
):
cli.cmd_select(args)
manifest = (project / ".pouch.yaml").read_text(encoding="utf-8")
self.assertEqual(installed, ["catalog-one"])
self.assertIn("builtin-one", manifest)
self.assertIn("source: builtin", manifest)
self.assertIn('name: "catalog-one"', manifest)
self.assertIn('source: "catalog:catalog-one"', manifest)
self.assertIn("targets:", manifest)
self.assertIn("codex", manifest)
self.assertEqual(
[choice.readonly_status for choice in selected_choices],
["全局: codex", "全局: codex"],
)
self.assertIn("全局状态只读", selected_scope[0])
def test_global_installation_note_reports_agents_and_source_conflicts(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
expected = root / "expected"
other = root / "other"
expected.mkdir()
other.mkdir()
global_dirs = {
target: root / target / "skills"
for target in ("cursor", "claude", "codex", "agents")
}
for directory in global_dirs.values():
directory.mkdir(parents=True)
(global_dirs["cursor"] / "ack").symlink_to(expected, target_is_directory=True)
(global_dirs["claude"] / "ack").symlink_to(other, target_is_directory=True)
with patch.object(
cli,
"agent_skill_dir",
side_effect=lambda target, project_root=None: global_dirs[target],
):
note = cli._global_installation_note(
"ack",
expected,
["cursor", "claude", "codex", "agents"],
)
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
def test_select_rejects_invalid_catalog_name_before_rendering(self) -> None:
args = argparse.Namespace(
agents=None,
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_pouch_home"),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(
cli,
"load_catalog",
return_value={"../../victim": {"repo": "https://example.test/repo.git"}},
),
patch.object(cli, "select_skills") as selector,
):
with self.assertRaisesRegex(SystemExit, "skill 名称无效"):
cli.cmd_select(args)
selector.assert_not_called()
def test_select_expands_catalog_collection_choices(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
selected_choices: list[SkillChoice] = []
installed: list[tuple[str, str | None]] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return {"waza/think"}
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_pouch_home"),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(
cli,
"load_catalog",
return_value={
"waza": {
"repo": "https://example.test/waza.git",
"ref": "main",
"path": "skills",
}
},
),
patch.object(
cli,
"_catalog_skill_names",
return_value=["think", "ui"],
),
patch.object(cli, "_list_fully_installed_names", return_value=[]),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
patch.object(
cli,
"_install_skill",
side_effect=lambda name, targets, project_root, **kwargs: installed.append(
(name, kwargs.get("source"))
),
),
):
cli.cmd_select(args)
self.assertEqual(
[choice.name for choice in selected_choices],
["waza", "waza/think", "waza/ui"],
)
self.assertEqual(selected_choices[0].children, ("waza/think", "waza/ui"))
self.assertEqual([choice.indent for choice in selected_choices[1:]], [1, 1])
self.assertEqual(installed, [("think", "catalog:waza")])
def test_select_expands_custom_collection_choices(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
selected_choices: list[SkillChoice] = []
installed: list[tuple[str, str | None]] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return {"company/review"}
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
review = root / "review"
release = root / "release"
review.mkdir()
release.mkdir()
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_pouch_home"),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(cli, "load_catalog", return_value={}),
patch.object(
cli,
"load_sources",
return_value={"company": {"local_path": str(root)}},
),
patch.object(cli, "_ensure_source_fetched"),
patch.object(
cli,
"discover_source_skills",
return_value={"review": review, "release": release},
),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
patch.object(
cli,
"_install_skill",
side_effect=lambda name, targets, project_root, **kwargs: installed.append(
(name, kwargs.get("source"))
),
),
):
cli.cmd_select(args)
self.assertEqual(
[choice.name for choice in selected_choices],
["company", "company/review", "company/release"],
)
self.assertEqual(
selected_choices[0].children,
("company/review", "company/release"),
)
self.assertEqual(installed, [("review", "company")])
def test_select_deduplicates_local_catalog_collection_from_builtin(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
with tempfile.TemporaryDirectory() as temp:
skills_root = Path(temp) / "skills"
(skills_root / "ack").mkdir(parents=True)
selected_choices: list[SkillChoice] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return set()
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "SKILLS_DIR", skills_root),
patch.object(cli, "ensure_pouch_home"),
patch.object(cli, "list_builtin_skills", return_value=["ack"]),
patch.object(cli, "skill_description", return_value="builtin ack"),
patch.object(
cli,
"load_catalog",
return_value={
"skills": {
"repo": "~/.pouch",
"ref": "main",
"path": "skills",
}
},
),
patch.object(cli, "_catalog_skill_names", return_value=["ack"]),
patch.object(cli, "catalog_skill_path", return_value=skills_root),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
):
cli.cmd_select(args)
self.assertEqual([choice.name for choice in selected_choices], ["ack"])
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
skill = root / "source"
skill.mkdir()
skill.joinpath("SKILL.md").write_text("---\\n", encoding="utf-8")
links = {
"cursor": root / "cursor" / "demo",
"claude": root / "claude" / "demo",
}
links["claude"].mkdir(parents=True)
with (
patch.object(cli, "_ensure_source_fetched"),
patch.object(cli, "resolve_skill_source", return_value=(skill, "builtin")),
patch.object(
cli,
"agent_skill_dir",
side_effect=lambda target, project_root=None: links[target].parent,
),
):
with self.assertRaises(FileExistsError):
cli._install_skill("demo", ["cursor", "claude"])
self.assertFalse(links["cursor"].exists())
self.assertTrue(links["claude"].is_dir())
if __name__ == "__main__":
unittest.main()