feat(search): app/search/skills.py — 技能注册表与 frontmatter 解析

从 TRM4 core/search/skills.py 保真迁移。提供 parse_frontmatter、
strip_frontmatter、SkillRegistry、discover_skills 四个公共 API。
逻辑完全一致,仅调整导入路径并添加中文 docstring。

17 个单元测试全部通过,覆盖正常/异常/边界场景。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 05:46:25 -04:00
parent 82f1607195
commit 60e737e2fc
2 changed files with 409 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
"""app/search/skills 模块的单元测试。
覆盖 parse_frontmatter、strip_frontmatter、SkillRegistry、discover_skills。
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from app.search.skills import (
SkillRegistry,
discover_skills,
parse_frontmatter,
strip_frontmatter,
)
if TYPE_CHECKING:
from pathlib import Path
# ── parse_frontmatter ──────────────────────────────────────────────
class TestParseFrontmatter:
"""parse_frontmatter 的测试集。"""
def test_normal(self) -> None:
"""正常 frontmatter 应解析出目标字段。"""
text = (
"---\n"
"name: my_skill\n"
'description: "A cool skill"\n'
"always: true\n"
"task_type: qa\n"
"---\n"
"Body text here.\n"
)
result = parse_frontmatter(text)
assert result == {
"name": "my_skill",
"description": "A cool skill",
"always": "true",
"task_type": "qa",
}
def test_missing_closing_delimiter(self) -> None:
"""缺少结束 --- 应返回空字典。"""
text = "---\nname: orphan\ndescription: no end\n"
assert parse_frontmatter(text) == {}
def test_no_frontmatter(self) -> None:
"""不以 --- 开头的文本应返回空字典。"""
text = "Just plain markdown.\n"
assert parse_frontmatter(text) == {}
def test_ignores_unknown_fields(self) -> None:
"""非目标字段应被忽略。"""
text = "---\nname: s1\nauthor: someone\n---\nBody\n"
result = parse_frontmatter(text)
assert result == {"name": "s1"}
assert "author" not in result
def test_single_quoted_value(self) -> None:
"""单引号包裹的值应去除引号。"""
text = "---\nname: 'quoted_name'\n---\nBody\n"
result = parse_frontmatter(text)
assert result["name"] == "quoted_name"
# ── strip_frontmatter ──────────────────────────────────────────────
class TestStripFrontmatter:
"""strip_frontmatter 的测试集。"""
def test_strips_frontmatter(self) -> None:
"""正常情况下应去除 frontmatter,返回正文。"""
text = "---\nname: x\n---\nBody content.\n"
assert strip_frontmatter(text) == "Body content.\n"
def test_no_frontmatter_returns_original(self) -> None:
"""无 frontmatter 时应返回原文。"""
text = "No frontmatter here.\n"
assert strip_frontmatter(text) == text
def test_incomplete_frontmatter_returns_original(self) -> None:
"""不完整 frontmatter(缺结束符)应返回原文。"""
text = "---\nname: x\nstill going\n"
assert strip_frontmatter(text) == text
# ── SkillRegistry ──────────────────────────────────────────────────
class TestSkillRegistry:
"""SkillRegistry 的测试集。"""
def test_read_normal(self, tmp_path: Path) -> None:
"""read 应返回去除 frontmatter 后的正文。"""
skill_file = tmp_path / "skill_a.md"
skill_file.write_text("---\nname: skill_a\n---\nSkill A body.\n", encoding="utf-8")
registry = SkillRegistry()
registry.set_paths({"skill_a": skill_file})
assert registry.read("skill_a") == "Skill A body.\n"
def test_read_unregistered_raises_key_error(self) -> None:
"""读取未注册的技能应抛出 KeyError。"""
registry = SkillRegistry()
with pytest.raises(KeyError):
registry.read("nonexistent")
# ── discover_skills ────────────────────────────────────────────────
class TestDiscoverSkills:
"""discover_skills 的测试集。"""
def _write_skill(self, path: Path, content: str) -> None:
"""辅助方法:写入技能文件。"""
path.write_text(content, encoding="utf-8")
def test_always_skill(self, tmp_path: Path) -> None:
"""always=true 的技能应出现在 always_text 中。"""
self._write_skill(
tmp_path / "always_skill.md",
"---\nname: a1\ndescription: always on\nalways: true\n---\nAlways body.\n",
)
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert "Always body." in always_text
assert task_map == {}
assert "a1" not in catalog_text
def test_task_type_skill(self, tmp_path: Path) -> None:
"""有 task_type 的非 always 技能应出现在 task_skill_map 中。"""
self._write_skill(
tmp_path / "task_skill.md",
"---\nname: t1\ndescription: task skill\ntask_type: qa\n---\nTask body.\n",
)
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert always_text == ""
assert task_map == {"qa": "Task body.\n"}
assert "t1" in catalog_text
def test_catalog_skill(self, tmp_path: Path) -> None:
"""普通技能应出现在 catalog_text 和 registry 中。"""
self._write_skill(
tmp_path / "cat_skill.md",
"---\nname: c1\ndescription: catalog skill\n---\nCatalog body.\n",
)
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert always_text == ""
assert task_map == {}
assert "**c1**" in catalog_text
assert "catalog skill" in catalog_text
assert registry.read("c1") == "Catalog body.\n"
def test_empty_directory(self, tmp_path: Path) -> None:
"""空目录应返回所有空值。"""
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert always_text == ""
assert task_map == {}
assert catalog_text == ""
def test_nonexistent_directory(self, tmp_path: Path) -> None:
"""不存在的目录应返回所有空值。"""
missing = tmp_path / "no_such_dir"
always_text, task_map, catalog_text, registry = discover_skills(missing)
assert always_text == ""
assert task_map == {}
assert catalog_text == ""
def test_mixed_skills(self, tmp_path: Path) -> None:
"""混合 always / task_type / catalog 技能应正确分类。"""
self._write_skill(
tmp_path / "01_always.md",
"---\nname: a1\ndescription: always\nalways: true\n---\nA body.\n",
)
self._write_skill(
tmp_path / "02_task.md",
"---\nname: t1\ndescription: task\ntask_type: summary\n---\nT body.\n",
)
self._write_skill(
tmp_path / "03_catalog.md",
"---\nname: c1\ndescription: catalog\n---\nC body.\n",
)
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert "A body." in always_text
assert task_map == {"summary": "T body.\n"}
assert "**t1**" in catalog_text
assert "**c1**" in catalog_text
# always 技能不应出现在 catalog 或 registry 中
assert "a1" not in catalog_text
def test_skip_no_name(self, tmp_path: Path) -> None:
"""没有 name 字段的技能文件应被跳过。"""
self._write_skill(
tmp_path / "bad.md",
"---\ndescription: no name\n---\nBody.\n",
)
always_text, task_map, catalog_text, registry = discover_skills(tmp_path)
assert always_text == ""
assert task_map == {}
assert catalog_text == ""