zcbot/tests/test_user_skills.py

206 lines
7.5 KiB
Python

"""用户 skill: 多来源覆盖(user wins)、加载失败收集、save_skill / fork_skill。"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from core.skills import SkillRegistry, SkillSource
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
def _write_skill(root: Path, name: str, desc: str, body: str = "body") -> Path:
d = root / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n\n{body}\n",
encoding="utf-8",
)
return d
class TestMultiSourceRegistry(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.builtin = self.root / "builtin"
self.user = self.root / "user"
self.builtin.mkdir()
self.user.mkdir()
def tearDown(self):
self.tmp.cleanup()
def _registry(self):
return SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user, "user"),
])
def test_user_overrides_builtin(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
_write_skill(self.user, "ppt", "我的 ppt")
reg = self._registry()
self.assertEqual(reg.get("ppt").source, "user")
self.assertEqual(reg.get("ppt").description, "我的 ppt")
self.assertIn("ppt", reg.user_overrides)
def test_distinct_names_coexist(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
_write_skill(self.user, "ppt-mine", "我的 ppt")
reg = self._registry()
self.assertEqual(reg.get("ppt").source, "builtin")
self.assertEqual(reg.get("ppt-mine").source, "user")
self.assertNotIn("ppt-mine", reg.user_overrides)
def test_discovery_block_tags_user_skills(self):
_write_skill(self.builtin, "coding", "内置 coding")
_write_skill(self.user, "ppt", "我的") # 不撞内置
_write_skill(self.builtin, "ppt", "内置 ppt")
reg = self._registry()
block = reg.discovery_block()
self.assertIn("[你的·已覆盖内置]", block) # user ppt 覆盖 builtin ppt
def test_bad_user_skill_collected_not_crash(self):
_write_skill(self.builtin, "coding", "内置")
# 用户 skill: 缺 description
bad = self.user / "broken"
bad.mkdir()
(bad / "SKILL.md").write_text("---\nname: broken\n---\nbody", encoding="utf-8")
reg = self._registry()
self.assertIn("coding", reg.skills) # 没崩,内置正常
self.assertNotIn("broken", reg.skills) # 坏的没收
self.assertTrue(any(n == "broken" for n, _ in reg.load_errors))
self.assertIn("未加载", reg.discovery_block())
def test_bad_yaml_user_skill_collected(self):
bad = self.user / "badyaml"
bad.mkdir()
(bad / "SKILL.md").write_text("---\nname: [unclosed\n---\nbody", encoding="utf-8")
reg = self._registry()
self.assertTrue(any(n == "badyaml" for n, _ in reg.load_errors))
def test_missing_user_dir_is_noop(self):
_write_skill(self.builtin, "coding", "内置")
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.root / "does-not-exist", "user"),
])
self.assertIn("coding", reg.skills)
self.assertEqual(reg.load_errors, [])
class TestSaveSkillTool(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.user_skills = self.root / ".skills"
self.builtin = self.root / "builtin"
self.builtin.mkdir()
def tearDown(self):
self.tmp.cleanup()
def _tool(self):
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
return SaveSkillTool(self.user_skills, reg)
def test_save_writes_file(self):
out = self._tool().execute(
name="mine",
content="---\nname: mine\ndescription: 我的 skill\n---\n\n# Mine\n",
)
self.assertIn("saved", out)
self.assertTrue((self.user_skills / "mine" / "SKILL.md").exists())
def test_reject_bad_name(self):
out = self._tool().execute(name="../evil", content="---\ndescription: x\n---\n")
self.assertIn("[Error]", out)
self.assertFalse((self.user_skills / "../evil").exists())
def test_reject_missing_description(self):
out = self._tool().execute(name="mine", content="---\nname: mine\n---\nbody")
self.assertIn("[Error]", out)
self.assertIn("description", out)
def test_reject_bad_yaml(self):
out = self._tool().execute(name="mine", content="---\nname: [bad\n---\nbody")
self.assertIn("[Error]", out)
def test_reject_name_mismatch(self):
out = self._tool().execute(
name="mine",
content="---\nname: other\ndescription: x\n---\n",
)
self.assertIn("[Error]", out)
self.assertIn("不一致", out)
def test_warns_on_builtin_override(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
out = self._tool().execute(
name="ppt",
content="---\nname: ppt\ndescription: 我的 ppt\n---\n",
)
self.assertIn("saved", out)
self.assertIn("覆盖内置", out)
class TestForkSkillTool(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.user_skills = self.root / ".skills"
self.builtin = self.root / "builtin"
self.builtin.mkdir()
# 带脚本的内置 skill
d = _write_skill(self.builtin, "ppt", "内置 ppt")
(d / "scripts").mkdir()
(d / "scripts" / "helper.py").write_text("X = 1\n", encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def _tool(self):
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
return ForkSkillTool(self.user_skills, reg)
def test_fork_copies_scripts_and_renames(self):
out = self._tool().execute(src="ppt", new_name="ppt-mine")
self.assertIn("forked", out)
dest = self.user_skills / "ppt-mine"
self.assertTrue((dest / "scripts" / "helper.py").exists()) # 脚本带过来
md = (dest / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("name: ppt-mine", md) # frontmatter name 对齐新名
self.assertNotIn("name: ppt\n", md)
def test_forked_renamed_skill_does_not_shadow_builtin(self):
self._tool().execute(src="ppt", new_name="ppt-mine")
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
# 改了名,内置 ppt 不被遮,新名独立存在
self.assertEqual(reg.get("ppt").source, "builtin")
self.assertEqual(reg.get("ppt-mine").source, "user")
def test_reject_existing_dest(self):
self._tool().execute(src="ppt", new_name="ppt-mine")
out = self._tool().execute(src="ppt", new_name="ppt-mine")
self.assertIn("[Error]", out)
self.assertIn("已存在", out)
def test_reject_unknown_src(self):
out = self._tool().execute(src="nope", new_name="x")
self.assertIn("[Error]", out)
self.assertIn("不存在", out)
if __name__ == "__main__":
unittest.main()