Files
blackbird/aibox/tests/test_skill_manager.py
qing eadba81c26 refactor(aibox): 抽离工具类与子系统,附单测基线
A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool)

Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行

新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
2026-08-13 15:48:55 +08:00

205 lines
6.5 KiB
Python
Raw Permalink 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.
"""subsystems.skill_manager 单元测试
覆盖:
- SkillManager 加载 Markdown 技能
- 加载可执行技能(--help)
- search / get_skill_content / get_all / reload / count
每个测试用独立临时技能目录,互不污染。
"""
import os
import pytest
from subsystems import SkillManager
from common import constants
@pytest.fixture
def isolated_skill_dir(tmp_path, monkeypatch):
"""每个测试用独立技能目录 + monkeypatch constants.SKILLS_DIR。"""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
monkeypatch.setattr(type(constants), "SKILLS_DIR",
property(lambda self: str(skills_dir)))
return skills_dir
@pytest.fixture
def sm(isolated_skill_dir):
"""构造 SkillManager(依赖 isolated_skill_dir fixture 注入目录)。"""
return SkillManager()
# ==================== 空目录 ====================
def test_empty_skills_dir(sm):
assert sm.count() == 0
assert sm.get_all() == []
# ==================== Markdown 技能 ====================
def test_loads_markdown_skill(isolated_skill_dir, sm):
"""Markdown 技能应被加载,简介取首行(# 标题去 # 前缀)。"""
skill_file = isolated_skill_dir / "weather.md"
skill_file.write_text("# 查询天气\n查询指定城市天气\n详细用法...", encoding="utf-8")
sm.reload()
assert sm.count() == 1
s = sm.get_skill("weather")
assert s is not None
assert s["summary"] == "查询天气"
assert s["is_markdown"] is True
assert s["is_executable"] is False
assert "查询指定城市天气" in s["content"]
def test_markdown_skill_content_retrieval(isolated_skill_dir, sm):
"""sm fixture 构造时目录为空,写入文件后需 reload 才能加载。"""
(isolated_skill_dir / "notes.md").write_text("# 笔记技能\n笔记内容ABC", encoding="utf-8")
sm.reload()
content = sm.get_skill_content("notes")
assert "笔记内容ABC" in content
def test_markdown_skill_empty_file_skipped(isolated_skill_dir, sm):
"""空文件应被跳过。"""
(isolated_skill_dir / "empty.md").write_text("", encoding="utf-8")
sm.reload()
assert sm.count() == 0
def test_markdown_skill_no_title_uses_filename(isolated_skill_dir, sm):
"""无 # 标题时,首行普通文本作为简介。"""
(isolated_skill_dir / "plain.md").write_text("这是普通首行\n第二行", encoding="utf-8")
sm.reload()
s = sm.get_skill("plain")
assert s["summary"] == "这是普通首行"
# ==================== 隐藏/下划线文件跳过 ====================
def test_hidden_file_skipped(isolated_skill_dir, sm):
(isolated_skill_dir / ".hidden.md").write_text("# 隐藏", encoding="utf-8")
(isolated_skill_dir / "_internal.md").write_text("# 内部", encoding="utf-8")
sm.reload()
assert sm.count() == 0
# ==================== 搜索 ====================
def test_search_by_name(isolated_skill_dir, sm):
(isolated_skill_dir / "weather.md").write_text("# 天气\n查天气", encoding="utf-8")
(isolated_skill_dir / "calculator.md").write_text("# 计算器\n算数", encoding="utf-8")
sm.reload()
results = sm.search("weather")
assert len(results) == 1
assert results[0]["name"] == "weather"
def test_search_by_summary(isolated_skill_dir, sm):
(isolated_skill_dir / "x.md").write_text("# 天气预报技能\n内容", encoding="utf-8")
sm.reload()
results = sm.search("天气")
assert len(results) == 1
def test_search_by_content(isolated_skill_dir, sm):
(isolated_skill_dir / "x.md").write_text("# 标题\n独特关键词XYZ123", encoding="utf-8")
sm.reload()
results = sm.search("XYZ123")
assert len(results) == 1
def test_search_no_match(isolated_skill_dir, sm):
(isolated_skill_dir / "x.md").write_text("# 标题\n内容", encoding="utf-8")
sm.reload()
assert sm.search("不存在的关键词") == []
def test_search_case_insensitive(isolated_skill_dir, sm):
(isolated_skill_dir / "Weather.md").write_text("# Weather Skill\ncontent", encoding="utf-8")
sm.reload()
assert len(sm.search("weather")) == 1
assert len(sm.search("WEATHER")) == 1
# ==================== get_all / count ====================
def test_get_all_returns_list(isolated_skill_dir, sm):
(isolated_skill_dir / "a.md").write_text("# A\n内容A", encoding="utf-8")
(isolated_skill_dir / "b.md").write_text("# B\n内容B", encoding="utf-8")
sm.reload()
all_skills = sm.get_all()
assert len(all_skills) == 2
assert all("name" in s and "summary" in s and "content" in s for s in all_skills)
assert sm.count() == 2
# ==================== reload ====================
def test_reload_picks_up_new_file(isolated_skill_dir, sm):
assert sm.count() == 0
(isolated_skill_dir / "new.md").write_text("# 新技能\n内容", encoding="utf-8")
n = sm.reload()
assert n == 1
assert sm.count() == 1
def test_reload_drops_deleted_file(isolated_skill_dir, sm):
f = isolated_skill_dir / "temp.md"
f.write_text("# 临时\n内容", encoding="utf-8")
sm.reload()
assert sm.count() == 1
f.unlink()
sm.reload()
assert sm.count() == 0
# ==================== 不存在的技能 ====================
def test_get_nonexistent_skill_returns_none(sm):
assert sm.get_skill("不存在") is None
def test_get_content_nonexistent_returns_none(sm):
assert sm.get_skill_content("不存在") is None
# ==================== 可执行技能(跨平台:Windows 用 .bat,Unix 用 .sh) ====================
def test_loads_executable_skill(isolated_skill_dir, sm):
"""加载一个可执行技能文件(带 --help)。
Windows 下用 .bat(SkillManager 的 _is_executable 识别 .bat),
Unix 下用 .sh 并加可执行权限。
"""
import os as _os
if _os.name == 'nt':
# Windows:用 .bat 文件
skill_file = isolated_skill_dir / "greet.bat"
skill_file.write_text(
"@echo off\r\n"
"echo 打招呼技能 - 输入名字打招呼\r\n",
encoding="utf-8"
)
else:
# Unix:用 .sh 并加可执行权限
skill_file = isolated_skill_dir / "greet.sh"
skill_file.write_text(
"#!/bin/bash\n"
"echo '打招呼技能 - 输入名字打招呼'\n",
encoding="utf-8"
)
_os.chmod(str(skill_file), 0o755)
sm.reload()
s = sm.get_skill("greet")
assert s is not None
assert s["is_executable"] is True
assert s["is_markdown"] is False
assert "打招呼" in s["summary"]