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 单向
This commit is contained in:
1 parent
8af8dec1b0
commit
eadba81c26
15 files changed
+3216
-1883
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,21 @@
|
||||
"""pytest 公共配置:把 aibox 目录加入 sys.path,桩掉可选第三方依赖。
|
||||
|
||||
aibox 主模块 aibox.py 依赖 websockets,但 tools/basic_tools.py 仅依赖
|
||||
common + 标准库,所以单测 basic_tools 不需要真实 websockets——桩掉即可
|
||||
让 `from tools import ...` 间接 import aibox.py 时不出错(虽然本测试
|
||||
不直接 import aibox,但保持环境干净)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
# 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common)
|
||||
AIBOX_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(AIBOX_DIR))
|
||||
|
||||
# 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装)
|
||||
if "websockets" not in sys.modules:
|
||||
sys.modules["websockets"] = types.ModuleType("websockets")
|
||||
|
||||
import pytest # noqa: E402
|
||||
@@ -0,0 +1,327 @@
|
||||
"""tools.basic_tools 单元测试
|
||||
|
||||
覆盖 A 批抽离的 7 个工具类:
|
||||
- FileToolBase : 路径安全检查
|
||||
- ReadFileTool : 读文件指引
|
||||
- WriteFileTool : 写文件指引
|
||||
- ListFilesTool : 列目录指引
|
||||
- SimpleWebCrawlerTool : 网页访问指引
|
||||
- GetCurrentTimeTool : 时间获取
|
||||
- CalculatorTool : 数学计算
|
||||
|
||||
设计原则:
|
||||
- 不依赖网络/文件系统副作用(除 FileToolBase 路径检查用临时文件)
|
||||
- 不依赖 DeepSeek API、websockets 等外部服务
|
||||
- 验证工具接口契约(name/description/parameters/execute 返回类型)
|
||||
- 验证命令行指引类工具的核心语义(返回包含正确命令关键词 + run_command 提示)
|
||||
"""
|
||||
|
||||
import os
|
||||
import queue
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import (
|
||||
FileToolBase,
|
||||
ReadFileTool,
|
||||
WriteFileTool,
|
||||
ListFilesTool,
|
||||
SimpleWebCrawlerTool,
|
||||
GetCurrentTimeTool,
|
||||
CalculatorTool,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 通用接口契约测试 ====================
|
||||
|
||||
# (工具类, 期望 get_name 返回值, 必填参数键列表)
|
||||
TOOL_CASES = [
|
||||
(ReadFileTool, "read_file", ["filepath"]),
|
||||
(WriteFileTool, "write_file", ["filepath", "content"]),
|
||||
(ListFilesTool, "list_files", []),
|
||||
(SimpleWebCrawlerTool, "web", ["action", "url"]),
|
||||
(GetCurrentTimeTool, "get_current_time", []),
|
||||
(CalculatorTool, "calculator", ["a", "b", "operation"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_cls,expected_name,required_keys", TOOL_CASES)
|
||||
def test_tool_interface_contract(tool_cls, expected_name, required_keys):
|
||||
"""所有工具必须满足接口契约:name/描述非空、parameters 是合法 schema、必填字段在 properties 中。"""
|
||||
tool = tool_cls()
|
||||
assert tool.get_name() == expected_name
|
||||
|
||||
desc = tool.get_description()
|
||||
assert isinstance(desc, str) and len(desc) > 0
|
||||
|
||||
params = tool.get_parameters()
|
||||
assert isinstance(params, dict)
|
||||
assert params.get("type") == "object"
|
||||
props = params.get("properties", {})
|
||||
assert isinstance(props, dict) and len(props) > 0
|
||||
for k in required_keys:
|
||||
assert k in props, f"必填参数 {k} 未出现在 properties 中"
|
||||
# required 中声明的字段必须在 properties 中
|
||||
for k in params.get("required", []):
|
||||
assert k in props
|
||||
|
||||
|
||||
# ==================== GetCurrentTimeTool ====================
|
||||
|
||||
def test_get_current_time_full_format():
|
||||
tool = GetCurrentTimeTool()
|
||||
r = tool.execute(format="full")
|
||||
assert "当前时间" in r
|
||||
# 应包含 YYYY-MM-DD HH:MM:SS 形式
|
||||
assert datetime.now().strftime("%Y-%m-%d") in r
|
||||
|
||||
|
||||
def test_get_current_time_date_format():
|
||||
tool = GetCurrentTimeTool()
|
||||
r = tool.execute(format="date")
|
||||
assert datetime.now().strftime("%Y-%m-%d") in r
|
||||
|
||||
|
||||
def test_get_current_time_time_format():
|
||||
tool = GetCurrentTimeTool()
|
||||
r = tool.execute(format="time")
|
||||
assert datetime.now().strftime("%H:%M") in r
|
||||
|
||||
|
||||
def test_get_current_time_streaming_emits_complete():
|
||||
tool = GetCurrentTimeTool()
|
||||
q: queue.Queue = queue.Queue()
|
||||
tool.execute_streaming(q, format="date")
|
||||
msgs = []
|
||||
while not q.empty():
|
||||
msgs.append(q.get_nowait())
|
||||
# 至少应有一个 complete 事件
|
||||
types = [m[0] for m in msgs]
|
||||
assert "complete" in types
|
||||
# complete 事件内容非空
|
||||
complete_msgs = [m[1] for m in msgs if m[0] == "complete"]
|
||||
assert complete_msgs and len(complete_msgs[0]) > 0
|
||||
|
||||
|
||||
# ==================== CalculatorTool ====================
|
||||
|
||||
def test_calculator_add():
|
||||
r = CalculatorTool().execute(a=3, b=4, operation="add")
|
||||
assert "7" in r and "计算成功" in r
|
||||
|
||||
|
||||
def test_calculator_subtract():
|
||||
r = CalculatorTool().execute(a=10, b=4, operation="subtract")
|
||||
assert "6" in r
|
||||
|
||||
|
||||
def test_calculator_multiply():
|
||||
r = CalculatorTool().execute(a=6, b=7, operation="multiply")
|
||||
assert "42" in r
|
||||
|
||||
|
||||
def test_calculator_divide():
|
||||
r = CalculatorTool().execute(a=20, b=4, operation="divide")
|
||||
assert "5" in r
|
||||
|
||||
|
||||
def test_calculator_divide_by_zero():
|
||||
r = CalculatorTool().execute(a=1, b=0, operation="divide")
|
||||
assert "除数不能为0" in r
|
||||
|
||||
|
||||
def test_calculator_unknown_operation():
|
||||
r = CalculatorTool().execute(a=1, b=1, operation="power")
|
||||
assert "未知运算" in r
|
||||
|
||||
|
||||
# ==================== ReadFileTool ====================
|
||||
|
||||
def test_read_file_head_returns_command_prompt():
|
||||
"""head 模式应返回含 head 命令 + run_command 指引的 markdown。"""
|
||||
r = ReadFileTool().execute(filepath="aibox.py", head=True, max_lines=10)
|
||||
assert "head -n 10" in r
|
||||
assert "run_command" in r
|
||||
|
||||
|
||||
def test_read_file_tail_returns_command_prompt():
|
||||
r = ReadFileTool().execute(filepath="aibox.py", tail=True, max_lines=20)
|
||||
assert "tail -n 20" in r
|
||||
assert "run_command" in r
|
||||
|
||||
|
||||
def test_read_file_range_returns_sed_command():
|
||||
r = ReadFileTool().execute(filepath="aibox.py", start_line=5, end_line=15)
|
||||
assert "sed -n '5,15p'" in r
|
||||
assert "run_command" in r
|
||||
|
||||
|
||||
def test_read_file_max_lines_cap_at_500():
|
||||
"""max_lines 应被钳制到 500 防止超大范围。"""
|
||||
r = ReadFileTool().execute(filepath="aibox.py", max_lines=99999)
|
||||
assert "sed -n '1,500p'" in r
|
||||
|
||||
|
||||
def test_read_file_missing_filepath_returns_error():
|
||||
r = ReadFileTool().execute(filepath=None)
|
||||
assert "❌" in r and "filepath" in r
|
||||
|
||||
|
||||
def test_read_file_nonexistent_returns_error():
|
||||
r = ReadFileTool().execute(filepath="/no/such/file_xyz.txt", head=True)
|
||||
assert "❌" in r
|
||||
|
||||
|
||||
# ==================== WriteFileTool ====================
|
||||
|
||||
def test_write_file_overwrite_mode():
|
||||
r = WriteFileTool().execute(filepath="output.txt", content="hello")
|
||||
assert ">" in r and "run_command" in r
|
||||
# 覆盖模式应使用单 > 重定向
|
||||
assert "cat > " in r
|
||||
|
||||
|
||||
def test_write_file_append_mode():
|
||||
r = WriteFileTool().execute(filepath="output.txt", content="more", mode="a")
|
||||
assert "cat >> " in r
|
||||
assert "run_command" in r
|
||||
|
||||
|
||||
def test_write_file_heredoc_uses_eof_marker():
|
||||
r = WriteFileTool().execute(filepath="out.txt", content="data")
|
||||
assert "AIBOX_EOF" in r
|
||||
|
||||
|
||||
def test_write_file_missing_content_returns_error():
|
||||
r = WriteFileTool().execute(filepath="out.txt", content=None)
|
||||
assert "❌" in r and "content" in r
|
||||
|
||||
|
||||
def test_write_file_creates_parent_dir_command():
|
||||
"""写深路径时应在命令中包含 mkdir -p。"""
|
||||
r = WriteFileTool().execute(filepath="sub/dir/out.txt", content="x")
|
||||
assert "mkdir -p" in r
|
||||
|
||||
|
||||
# ==================== ListFilesTool ====================
|
||||
|
||||
def test_list_files_default_no_hidden_flag():
|
||||
r = ListFilesTool().execute(directory=".")
|
||||
assert "ls -l " in r
|
||||
assert "run_command" in r
|
||||
|
||||
|
||||
def test_list_files_show_hidden_flag():
|
||||
r = ListFilesTool().execute(directory=".", show_hidden=True)
|
||||
assert "ls -la " in r
|
||||
|
||||
|
||||
def test_list_files_nonexistent_dir_returns_error():
|
||||
r = ListFilesTool().execute(directory="/no/such/dir_xyz")
|
||||
assert "❌" in r
|
||||
|
||||
|
||||
def test_list_files_path_is_file_returns_error(tmp_path):
|
||||
f = tmp_path / "afile.txt"
|
||||
f.write_text("x")
|
||||
r = ListFilesTool().execute(directory=str(f))
|
||||
assert "❌" in r and "不是目录" in r
|
||||
|
||||
|
||||
# ==================== SimpleWebCrawlerTool ====================
|
||||
|
||||
def test_web_get_action_returns_curl_prompt():
|
||||
r = SimpleWebCrawlerTool().execute(action="get", url="https://example.com")
|
||||
assert "curl -sL" in r
|
||||
assert "run_command" in r
|
||||
assert "https://example.com" in r
|
||||
|
||||
|
||||
def test_web_title_action_uses_grep():
|
||||
r = SimpleWebCrawlerTool().execute(action="title", url="https://example.com")
|
||||
assert "curl" in r and "<title>" in r
|
||||
|
||||
|
||||
def test_web_links_action_uses_grep():
|
||||
r = SimpleWebCrawlerTool().execute(action="links", url="https://example.com")
|
||||
assert "href" in r and "curl" in r
|
||||
|
||||
|
||||
def test_web_text_action_uses_sed():
|
||||
r = SimpleWebCrawlerTool().execute(action="text", url="https://example.com")
|
||||
assert "sed" in r and "curl" in r
|
||||
|
||||
|
||||
def test_web_search_requires_keyword():
|
||||
r = SimpleWebCrawlerTool().execute(action="search", url="https://example.com")
|
||||
assert "❌" in r and "keyword" in r
|
||||
|
||||
|
||||
def test_web_search_with_keyword_uses_grep_c():
|
||||
r = SimpleWebCrawlerTool().execute(
|
||||
action="search", url="https://example.com", keyword="hello"
|
||||
)
|
||||
assert "grep -c" in r and "hello" in r
|
||||
|
||||
|
||||
def test_web_unknown_action_returns_error():
|
||||
r = SimpleWebCrawlerTool().execute(action="download", url="https://example.com")
|
||||
assert "❌" in r and "未知操作" in r
|
||||
|
||||
|
||||
# ==================== FileToolBase ====================
|
||||
|
||||
# FileToolBase 是抽象基类(继承自 Tool 的抽象方法未实现),无法直接实例化。
|
||||
# 用一个最小具体子类来测试基类提供的 normalize_path / check_path_safety 方法。
|
||||
class _ConcreteFileTool(FileToolBase):
|
||||
def get_name(self): return "_test"
|
||||
def get_description(self): return "test"
|
||||
def get_parameters(self): return {"type": "object", "properties": {}}
|
||||
def execute(self, **kwargs): return ""
|
||||
|
||||
|
||||
def test_filetoolbase_normalize_path_expands_user():
|
||||
base = _ConcreteFileTool()
|
||||
p = base.normalize_path("~/foo")
|
||||
assert "~" not in p
|
||||
assert os.path.isabs(p)
|
||||
|
||||
|
||||
def test_filetoolbase_check_path_safety_existing_file(tmp_path):
|
||||
f = tmp_path / "ok.txt"
|
||||
f.write_text("hi")
|
||||
base = _ConcreteFileTool()
|
||||
ok, abs_path, err = base.check_path_safety(str(f), check_exists=True)
|
||||
assert ok is True
|
||||
assert abs_path == str(f)
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_filetoolbase_check_path_safety_nonexistent_with_check(tmp_path):
|
||||
base = _ConcreteFileTool()
|
||||
ok, _, err = base.check_path_safety(str(tmp_path / "missing.txt"), check_exists=True)
|
||||
assert ok is False
|
||||
assert "文件不存在" in err
|
||||
|
||||
|
||||
def test_filetoolbase_check_path_safety_oversize_file(tmp_path):
|
||||
"""超大文件应被拒绝。"""
|
||||
f = tmp_path / "big.bin"
|
||||
# 写入略超 MAX_FILE_SIZE 的内容
|
||||
from common import constants
|
||||
f.write_bytes(b"x" * (constants.MAX_FILE_SIZE + 1))
|
||||
base = _ConcreteFileTool()
|
||||
ok, _, err = base.check_path_safety(str(f))
|
||||
assert ok is False
|
||||
assert "文件太大" in err
|
||||
|
||||
|
||||
def test_filetoolbase_is_subclass_of_tool():
|
||||
"""FileToolBase 应是 Tool 的子类,且三个文件工具都继承它。"""
|
||||
from tools import Tool
|
||||
assert issubclass(FileToolBase, Tool)
|
||||
assert issubclass(ReadFileTool, FileToolBase)
|
||||
assert issubclass(WriteFileTool, FileToolBase)
|
||||
assert issubclass(ListFilesTool, FileToolBase)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""subsystems.db 单元测试
|
||||
|
||||
覆盖:
|
||||
- MemoryDatabase: 增删查 / FTS5 全文检索 / 批量删除
|
||||
- MemoDatabase: 增删查 / 待执行 / 重复任务 / 即时任务 / 统计
|
||||
每个测试用独立临时 sqlite 文件,互不污染。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from subsystems import MemoryDatabase, MemoDatabase
|
||||
from common import constants
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mem_db(tmp_path):
|
||||
"""独立临时文件的 MemoryDatabase。"""
|
||||
db_path = str(tmp_path / "test_memory.db")
|
||||
return MemoryDatabase(db_path=db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memo_db(tmp_path):
|
||||
"""独立临时文件的 MemoDatabase。"""
|
||||
db_path = str(tmp_path / "test_memo.db")
|
||||
return MemoDatabase(db_path=db_path)
|
||||
|
||||
|
||||
# ==================== MemoryDatabase ====================
|
||||
|
||||
def test_memory_add_and_get(mem_db):
|
||||
mid = mem_db.add_memory(
|
||||
topic="测试主题", summary="测试摘要",
|
||||
content="详细内容", tags=["t1", "t2"]
|
||||
)
|
||||
assert isinstance(mid, int) and mid > 0
|
||||
|
||||
m = mem_db.get_memory_by_id(mid)
|
||||
assert m is not None
|
||||
assert m["topic"] == "测试主题"
|
||||
assert m["summary"] == "测试摘要"
|
||||
assert m["tags"] == ["t1", "t2"]
|
||||
|
||||
|
||||
def test_memory_search_by_keyword(mem_db):
|
||||
mem_db.add_memory(topic="Python learning", summary="learn Python basics", content="variables and functions")
|
||||
mem_db.add_memory(topic="Go learning", summary="learn Go basics", content="goroutine")
|
||||
|
||||
results = mem_db.search_memories(["Python"])
|
||||
assert len(results) == 1
|
||||
assert "Python" in results[0]["topic"]
|
||||
|
||||
|
||||
def test_memory_search_match_all(mem_db):
|
||||
mem_db.add_memory(topic="Python database", summary="sqlite usage", content="FTS5 search")
|
||||
mem_db.add_memory(topic="Python network", summary="requests usage", content="HTTP request")
|
||||
|
||||
# match_all=True:两个关键词都要命中
|
||||
results = mem_db.search_memories(["Python", "sqlite"], match_all=True)
|
||||
assert len(results) == 1
|
||||
assert "database" in results[0]["topic"]
|
||||
|
||||
# match_all=False:任一关键词命中
|
||||
results = mem_db.search_memories(["Python", "sqlite"], match_all=False)
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
def test_memory_search_empty_keywords_returns_empty(mem_db):
|
||||
assert mem_db.search_memories([]) == []
|
||||
|
||||
|
||||
def test_memory_delete(mem_db):
|
||||
mid = mem_db.add_memory(topic="待删除", summary="摘要")
|
||||
assert mem_db.delete_memory(mid) is True
|
||||
assert mem_db.get_memory_by_id(mid) is None
|
||||
# 再次删除应返回 False
|
||||
assert mem_db.delete_memory(mid) is False
|
||||
|
||||
|
||||
def test_memory_batch_delete(mem_db):
|
||||
ids = [mem_db.add_memory(topic=f"t{i}", summary=f"s{i}") for i in range(3)]
|
||||
deleted = mem_db.delete_memories_by_ids(ids)
|
||||
assert deleted == 3
|
||||
for mid in ids:
|
||||
assert mem_db.get_memory_by_id(mid) is None
|
||||
|
||||
|
||||
def test_memory_batch_delete_empty_list(mem_db):
|
||||
assert mem_db.delete_memories_by_ids([]) == 0
|
||||
|
||||
|
||||
def test_memory_get_recent(mem_db):
|
||||
for i in range(5):
|
||||
mem_db.add_memory(topic=f"recent_{i}", summary=f"s{i}")
|
||||
recent = mem_db.get_recent_memories(limit=3)
|
||||
assert len(recent) == 3
|
||||
|
||||
|
||||
def test_memory_nonexistent_id_returns_none(mem_db):
|
||||
assert mem_db.get_memory_by_id(99999) is None
|
||||
|
||||
|
||||
# ==================== MemoDatabase ====================
|
||||
|
||||
def test_memo_add_and_get(memo_db):
|
||||
reminder = (datetime.now() + timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(title="测试任务", content="内容", reminder_time=reminder)
|
||||
assert isinstance(mid, int) and mid > 0
|
||||
|
||||
m = memo_db.get_memo(mid)
|
||||
assert m is not None
|
||||
assert m["title"] == "测试任务"
|
||||
assert m["is_completed"] == 0
|
||||
|
||||
|
||||
def test_memo_pending_filter_by_time(memo_db):
|
||||
"""未到期的任务不应出现在 pending 列表中。"""
|
||||
future = (datetime.now() + timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
past = (datetime.now() - timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
memo_db.add_memo(title="未来任务", reminder_time=future)
|
||||
memo_db.add_memo(title="过期任务", reminder_time=past)
|
||||
|
||||
pending = memo_db.get_pending_memos()
|
||||
titles = [m["title"] for m in pending]
|
||||
assert "过期任务" in titles
|
||||
assert "未来任务" not in titles
|
||||
|
||||
|
||||
def test_memo_complete(memo_db):
|
||||
reminder = (datetime.now() + timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(title="一次性任务", reminder_time=reminder)
|
||||
assert memo_db.complete_memo(mid) is True
|
||||
|
||||
m = memo_db.get_memo(mid)
|
||||
assert m["is_completed"] == 1
|
||||
|
||||
|
||||
def test_memo_complete_nonexistent_returns_false(memo_db):
|
||||
assert memo_db.complete_memo(99999) is False
|
||||
|
||||
|
||||
def test_memo_immediate(memo_db):
|
||||
"""即时任务应被 get_immediate_memos 返回。"""
|
||||
mid = memo_db.add_memo(title="即时任务", is_immediate=True)
|
||||
imm = memo_db.get_immediate_memos()
|
||||
assert any(m["id"] == mid for m in imm)
|
||||
|
||||
|
||||
def test_memo_mark_as_triggered_completes_one_shot(memo_db):
|
||||
"""一次性任务触发后应标记完成。"""
|
||||
past = (datetime.now() - timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(title="一次性", reminder_time=past)
|
||||
nxt = memo_db.mark_as_triggered(mid)
|
||||
assert nxt is None # 一次性任务无下次提醒
|
||||
m = memo_db.get_memo(mid)
|
||||
assert m["is_completed"] == 1
|
||||
assert m["trigger_count"] == 1
|
||||
|
||||
|
||||
def test_memo_mark_as_triggered_daily_repeat(memo_db):
|
||||
"""daily 重复任务触发后应计算下次提醒时间。"""
|
||||
past = (datetime.now() - timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(title="每日任务", reminder_time=past, repeat_type="daily")
|
||||
nxt = memo_db.mark_as_triggered(mid)
|
||||
assert nxt is not None
|
||||
# 下次应在未来
|
||||
next_dt = datetime.strptime(nxt, constants.DATETIME_FORMAT)
|
||||
assert next_dt > datetime.now() - timedelta(minutes=1)
|
||||
m = memo_db.get_memo(mid)
|
||||
assert m["is_completed"] == 0 # 重复任务不完成
|
||||
|
||||
|
||||
def test_memo_stats(memo_db):
|
||||
past = (datetime.now() - timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
future = (datetime.now() + timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
memo_db.add_memo(title="过期", reminder_time=past)
|
||||
memo_db.add_memo(title="未来", reminder_time=future)
|
||||
memo_db.add_memo(title="即时", is_immediate=True)
|
||||
|
||||
stats = memo_db.get_memo_stats()
|
||||
assert stats["total"] == 3
|
||||
assert stats["pending"] == 3
|
||||
assert stats["overdue"] == 1 # 只有过期且非即时的
|
||||
assert stats["immediate"] == 1
|
||||
|
||||
|
||||
def test_memo_get_all_excludes_completed_by_default(memo_db):
|
||||
reminder = (datetime.now() + timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(title="待完成", reminder_time=reminder)
|
||||
memo_db.complete_memo(mid)
|
||||
|
||||
all_memos = memo_db.get_all_memos(include_completed=False)
|
||||
assert all(m["is_completed"] == 0 for m in all_memos)
|
||||
|
||||
all_with_completed = memo_db.get_all_memos(include_completed=True)
|
||||
assert any(m["id"] == mid for m in all_with_completed)
|
||||
|
||||
|
||||
def test_memo_get_pending_tasks_for_user(memo_db):
|
||||
past = (datetime.now() - timedelta(days=1)).strftime(constants.DATETIME_FORMAT)
|
||||
memo_db.add_memo(title="用户任务", reminder_time=past)
|
||||
|
||||
tasks = memo_db.get_pending_tasks_for_user()
|
||||
assert len(tasks) >= 1
|
||||
assert all("id" in t and "title" in t and "is_overdue" in t for t in tasks)
|
||||
assert any(t["title"] == "用户任务" and t["is_overdue"] for t in tasks)
|
||||
|
||||
|
||||
def test_memo_add_user_task_disabled(monkeypatch, memo_db):
|
||||
"""当 MEMO_ALLOW_USER_TASKS=False 时应抛异常。"""
|
||||
monkeypatch.setattr(type(constants), "MEMO_ALLOW_USER_TASKS",
|
||||
property(lambda self: False))
|
||||
with pytest.raises(Exception, match="禁用"):
|
||||
memo_db.add_user_task(title="x")
|
||||
|
||||
|
||||
def test_memo_custom_repeat_minutes(memo_db):
|
||||
"""custom 重复 + minutes 单位应正确计算下次。"""
|
||||
past = (datetime.now() - timedelta(minutes=1)).strftime(constants.DATETIME_FORMAT)
|
||||
mid = memo_db.add_memo(
|
||||
title="每5分钟", reminder_time=past,
|
||||
repeat_type="custom", repeat_interval=5, repeat_interval_unit="minutes"
|
||||
)
|
||||
nxt = memo_db.mark_as_triggered(mid)
|
||||
assert nxt is not None
|
||||
next_dt = datetime.strptime(nxt, constants.DATETIME_FORMAT)
|
||||
# 下次约在 5 分钟后
|
||||
delta = next_dt - datetime.now()
|
||||
assert timedelta(minutes=4) < delta < timedelta(minutes=6)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""subsystems.executors 单元测试
|
||||
|
||||
覆盖:
|
||||
- CommandExecutor 抽象基类契约
|
||||
- SubprocessCommandExecutor 命令执行/流式/超时/取消/输出限制
|
||||
"""
|
||||
import queue
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from subsystems import CommandExecutor, SubprocessCommandExecutor
|
||||
from common import constants
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def use_tmp_workspace(tmp_path, monkeypatch):
|
||||
"""每个测试把 WORKSPACE_DIR 重定向到临时目录(并预先创建),避免默认 ~/.aibox/workspace 不存在。"""
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
monkeypatch.setattr(type(constants), "WORKSPACE_DIR",
|
||||
property(lambda self: str(ws)))
|
||||
|
||||
|
||||
# ==================== 抽象基类契约 ====================
|
||||
|
||||
def test_command_executor_is_abstract():
|
||||
"""CommandExecutor 是 ABC,不能直接实例化。"""
|
||||
with pytest.raises(TypeError):
|
||||
CommandExecutor()
|
||||
|
||||
|
||||
def test_subprocess_executor_is_subclass():
|
||||
assert issubclass(SubprocessCommandExecutor, CommandExecutor)
|
||||
|
||||
|
||||
def test_executor_is_enabled_reflects_constants():
|
||||
"""is_enabled 应反映 constants.COMMANDS_ENABLED。"""
|
||||
from common import constants
|
||||
ex = SubprocessCommandExecutor()
|
||||
assert ex.is_enabled() == constants.COMMANDS_ENABLED
|
||||
|
||||
|
||||
def test_executor_format_result_success():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.format_result(True, "ok", {"k": "v"})
|
||||
assert r.startswith("✅ ok")
|
||||
assert "k: v" in r
|
||||
|
||||
|
||||
def test_executor_format_result_failure():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.format_result(False, "fail")
|
||||
assert r.startswith("❌ fail")
|
||||
|
||||
|
||||
def test_executor_format_result_multiline_value():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.format_result(True, "ok", {"输出": "line1\nline2"})
|
||||
assert "line1" in r and "line2" in r
|
||||
|
||||
|
||||
# ==================== 实际命令执行 ====================
|
||||
|
||||
def test_execute_echo_command():
|
||||
"""执行 echo 命令应返回成功结果。"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.execute("echo hello_test_123", timeout=5)
|
||||
assert "hello_test_123" in r
|
||||
assert "✅" in r
|
||||
|
||||
|
||||
def test_execute_command_returns_exit_code():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.execute("exit 0", timeout=5)
|
||||
assert "返回码: 0" in r
|
||||
|
||||
|
||||
def test_execute_failing_command_returns_nonzero_code():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.execute("exit 7", timeout=5)
|
||||
assert "返回码: 7" in r
|
||||
# 非零返回码应标记为失败
|
||||
assert "❌" in r
|
||||
|
||||
|
||||
def test_execute_timeout():
|
||||
"""超时应被检测并返回错误信息。"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
# ping -t 在 Windows 上无限循环;用 ping -n 跨平台
|
||||
r = ex.execute("ping -n 100 127.0.0.1", timeout=2)
|
||||
assert "超时" in r
|
||||
|
||||
|
||||
def test_execute_unknown_command_returns_error():
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.execute("nonexistent_command_xyz_123", timeout=5)
|
||||
# shell=True 下会返回 127 或类似错误码,但不会抛异常
|
||||
assert "返回码" in r
|
||||
|
||||
|
||||
# ==================== 流式执行 ====================
|
||||
|
||||
def test_execute_streaming_emits_lines_and_complete():
|
||||
ex = SubprocessCommandExecutor()
|
||||
q: queue.Queue = queue.Queue()
|
||||
ex.execute_streaming("echo stream_line_test", q, timeout=5)
|
||||
|
||||
msgs = []
|
||||
while not q.empty():
|
||||
msgs.append(q.get_nowait())
|
||||
|
||||
types = [m[0] for m in msgs]
|
||||
assert "line" in types
|
||||
assert "complete" in types
|
||||
|
||||
complete_payload = [m[1] for m in msgs if m[0] == "complete"][0]
|
||||
assert "stream_line_test" in complete_payload
|
||||
|
||||
|
||||
def test_execute_streaming_emits_command_header():
|
||||
"""流式执行开头应输出命令/超时/工作目录等头部信息。"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
q: queue.Queue = queue.Queue()
|
||||
ex.execute_streaming("echo hi", q, timeout=5)
|
||||
|
||||
lines = []
|
||||
while not q.empty():
|
||||
t, p = q.get_nowait()
|
||||
if t == "line":
|
||||
lines.append(p)
|
||||
|
||||
header = "".join(lines[:6])
|
||||
assert "执行命令" in header
|
||||
assert "超时" in header
|
||||
assert "工作目录" in header
|
||||
|
||||
|
||||
# ==================== 取消机制 ====================
|
||||
|
||||
def test_cancel_terminates_running_process():
|
||||
"""cancel() 在进程运行时调用应不抛异常并尝试 kill 进程。
|
||||
Windows 下 kill 后管道读取可能短暂阻塞,但 cancel 本身必须安全返回。
|
||||
线程设为 daemon,确保不会卡住测试进程退出。
|
||||
"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
q: queue.Queue = queue.Queue()
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=ex.execute_streaming,
|
||||
args=("ping -n 100 127.0.0.1", q),
|
||||
kwargs={"timeout": 60},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
time.sleep(0.5) # 等进程启动
|
||||
# 关键断言:cancel 不抛异常(若抛异常测试即失败)
|
||||
ex.cancel()
|
||||
# 等待线程退出(Windows 下可能因管道阻塞超时,daemon 确保不卡死主进程)
|
||||
t.join(timeout=5)
|
||||
|
||||
|
||||
def test_cancel_when_no_process_running_is_noop():
|
||||
"""无进程运行时调用 cancel 应是空操作。"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
ex.cancel() # 不应抛异常
|
||||
|
||||
|
||||
# ==================== 输出大小限制 ====================
|
||||
|
||||
def test_output_size_limit_truncates():
|
||||
"""输出超过 1MB 应被截断。"""
|
||||
ex = SubprocessCommandExecutor()
|
||||
# 生成约 2MB 输出
|
||||
r = ex.execute("python -c \"print('x'*2048576)\"", timeout=10)
|
||||
assert "截断" in r or "❌" in r
|
||||
|
||||
|
||||
# ==================== 禁用场景 ====================
|
||||
|
||||
def test_execute_when_disabled_returns_disabled_message(monkeypatch):
|
||||
"""当 COMMANDS_ENABLED=False 时应返回禁用提示。"""
|
||||
from common import constants
|
||||
monkeypatch.setattr(type(constants), "COMMANDS_ENABLED",
|
||||
property(lambda self: False))
|
||||
ex = SubprocessCommandExecutor()
|
||||
r = ex.execute("echo hi", timeout=5)
|
||||
assert "禁用" in r
|
||||
@@ -0,0 +1,190 @@
|
||||
"""subsystems.prompt_manager 单元测试
|
||||
|
||||
覆盖:
|
||||
- PromptManager 单例语义
|
||||
- 提示词加载/排序/拼接
|
||||
- identity/soul/status 读写
|
||||
- 从模板复制行为
|
||||
- reload 语义
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from subsystems import PromptManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_pm(tmp_path, monkeypatch):
|
||||
"""每个测试用独立 prompts 目录 + 重置单例,避免测试间状态污染。"""
|
||||
prompts_dir = tmp_path / "prompts"
|
||||
prompts_dir.mkdir()
|
||||
|
||||
# 重置单例状态
|
||||
PromptManager._instance = None
|
||||
PromptManager._prompts = []
|
||||
PromptManager._prompts_dir = None
|
||||
|
||||
# 让 ConfigManager 返回临时目录
|
||||
from common import ConfigManager
|
||||
original_get = ConfigManager.get
|
||||
|
||||
def mock_get(self, key, default=None):
|
||||
if key == "system.prompts_dir":
|
||||
return str(prompts_dir)
|
||||
return original_get(self, key, default)
|
||||
|
||||
monkeypatch.setattr(ConfigManager, "get", mock_get)
|
||||
|
||||
pm = PromptManager()
|
||||
yield pm
|
||||
|
||||
# 清理单例
|
||||
PromptManager._instance = None
|
||||
PromptManager._prompts = []
|
||||
PromptManager._prompts_dir = None
|
||||
|
||||
|
||||
# ==================== 单例 ====================
|
||||
|
||||
def test_prompt_manager_is_singleton(isolated_pm):
|
||||
pm2 = PromptManager()
|
||||
assert pm2 is isolated_pm
|
||||
|
||||
|
||||
def test_prompt_manager_resets_between_tests(isolated_pm):
|
||||
"""fixture 应重置单例,确保测试隔离。"""
|
||||
assert PromptManager._instance is isolated_pm
|
||||
|
||||
|
||||
# ==================== 加载与排序 ====================
|
||||
|
||||
def test_loads_template_files(isolated_pm):
|
||||
"""首次构造应从 aibox/prompts/ 模板复制核心文件。"""
|
||||
files = os.listdir(isolated_pm._prompts_dir)
|
||||
# 至少应有 00_IDENTITY / 99_SOUL / 100_STATUS
|
||||
assert "00_IDENTITY.md" in files
|
||||
assert "99_SOUL.md" in files
|
||||
assert "100_STATUS.md" in files
|
||||
|
||||
|
||||
def test_get_prompts_returns_nonempty_string(isolated_pm):
|
||||
s = isolated_pm.get_prompts()
|
||||
assert isinstance(s, str) and len(s) > 0
|
||||
|
||||
|
||||
def test_get_sorted_prompts_list_returns_list(isolated_pm):
|
||||
lst = isolated_pm.get_sorted_prompts_list()
|
||||
assert isinstance(lst, list)
|
||||
assert len(lst) > 0
|
||||
assert all("file" in p and "content" in p for p in lst)
|
||||
|
||||
|
||||
def test_prompt_sort_order_identity_first(isolated_pm):
|
||||
"""00_IDENTITY 应排在最前。"""
|
||||
lst = isolated_pm.get_sorted_prompts_list()
|
||||
assert lst[0]["file"] == "00_IDENTITY.md"
|
||||
|
||||
|
||||
def test_prompt_sort_order_status_last_or_near_last(isolated_pm):
|
||||
"""100_STATUS 应在 SOUL 之后。"""
|
||||
lst = isolated_pm.get_sorted_prompts_list()
|
||||
files = [p["file"] for p in lst]
|
||||
if "100_STATUS.md" in files and "99_SOUL.md" in files:
|
||||
assert files.index("100_STATUS.md") > files.index("99_SOUL.md")
|
||||
|
||||
|
||||
def test_skill_prompts_filtered_correctly(isolated_pm):
|
||||
"""N_ 开头的技能文件应被 get_skill_prompts 识别。"""
|
||||
skills = isolated_pm.get_skill_prompts()
|
||||
# 模板里至少有一个 1_xxx.md 技能文件
|
||||
assert len(skills) >= 0 # 至少不报错
|
||||
for p in skills:
|
||||
assert p["file"][0].isdigit()
|
||||
|
||||
|
||||
# ==================== 读写 identity/soul/status ====================
|
||||
|
||||
def test_update_and_get_identity(isolated_pm):
|
||||
ok = isolated_pm.update_identity("测试用户-张三")
|
||||
assert ok is True
|
||||
content = isolated_pm.get_identity()
|
||||
assert "测试用户-张三" in content
|
||||
assert "用户身份信息" in content
|
||||
|
||||
|
||||
def test_update_and_get_soul(isolated_pm):
|
||||
ok = isolated_pm.update_soul("我是测试智能体")
|
||||
assert ok is True
|
||||
content = isolated_pm.get_soul()
|
||||
assert "我是测试智能体" in content
|
||||
|
||||
|
||||
def test_update_and_get_status(isolated_pm):
|
||||
ok = isolated_pm.update_status("测试状态-初始化完成")
|
||||
assert ok is True
|
||||
content = isolated_pm.get_status()
|
||||
assert "测试状态-初始化完成" in content
|
||||
|
||||
|
||||
def test_get_identity_nonexistent_returns_fallback(monkeypatch, tmp_path):
|
||||
"""无 identity 文件时应返回兜底字符串。"""
|
||||
PromptManager._instance = None
|
||||
PromptManager._prompts = []
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
from common import ConfigManager
|
||||
original_get = ConfigManager.get
|
||||
|
||||
def mock_get(self, key, default=None):
|
||||
if key == "system.prompts_dir":
|
||||
return str(empty_dir)
|
||||
return original_get(self, key, default)
|
||||
|
||||
monkeypatch.setattr(ConfigManager, "get", mock_get)
|
||||
pm = PromptManager()
|
||||
# 删除 identity 文件模拟不存在
|
||||
id_file = empty_dir / "00_IDENTITY.md"
|
||||
if id_file.exists():
|
||||
id_file.unlink()
|
||||
r = pm.get_identity()
|
||||
assert "尚不清楚" in r or "无法读取" in r
|
||||
|
||||
PromptManager._instance = None
|
||||
PromptManager._prompts = []
|
||||
PromptManager._prompts_dir = None
|
||||
|
||||
|
||||
# ==================== reload ====================
|
||||
|
||||
def test_reload_returns_count(isolated_pm):
|
||||
n = isolated_pm.reload()
|
||||
assert isinstance(n, int) and n >= 0
|
||||
|
||||
|
||||
def test_reload_picks_up_new_file(isolated_pm, tmp_path):
|
||||
"""reload 应能发现新增的提示词文件。"""
|
||||
before = isolated_pm.get_prompt_count()
|
||||
# 写入一个新的提示词文件
|
||||
new_file = os.path.join(isolated_pm._prompts_dir, "99_EXTRA_TEST.md")
|
||||
with open(new_file, "w", encoding="utf-8") as f:
|
||||
f.write("# 额外测试提示词\n内容")
|
||||
isolated_pm.reload()
|
||||
after = isolated_pm.get_prompt_count()
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
# ==================== 模板不覆盖已存在文件 ====================
|
||||
|
||||
def test_existing_file_not_overwritten_by_template(isolated_pm):
|
||||
"""已存在的运行时文件不应被模板覆盖。"""
|
||||
# 修改 identity 文件
|
||||
isolated_pm.update_identity("我的自定义身份")
|
||||
custom_content = isolated_pm.get_identity()
|
||||
assert "我的自定义身份" in custom_content
|
||||
|
||||
# reload 触发 _ensure_from_template,但不应覆盖
|
||||
isolated_pm.reload()
|
||||
after = isolated_pm.get_identity()
|
||||
assert "我的自定义身份" in after
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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"]
|
||||
Reference in new issue
Block a user