Files
blackbird/aibox/tests/test_basic_tools.py
qing d47ef6efb4 docs(aibox): 同步v3.0模块化文档,修复测试路径漂移
- README.md: 更新行数(8450->3706)、重写目录结构(含tools/subsystems/tests包)、补充包依赖方向与测试运行说明、新增v3.0更新日志
- test_basic_tools.py: 4个ReadFileTool测试改用tmp_path fixture,消除cwd依赖
- 新增 docs/reports/aibox_refactor_progress_20260814.md: 重构进度报告
2026-08-13 16:26:41 +08:00

340 lines
11 KiB
Python

"""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 ====================
#
# 说明:使用 pytest 的 tmp_path fixture 创建临时文件作为 read_file 入参,
# 不依赖任何相对路径或 cwd——早期版本用 filepath="aibox.py" 假设从
# aibox/ 目录运行 pytest,从仓库根运行会因路径漂移失败(2026-08-14 修复)。
def test_read_file_head_returns_command_prompt(tmp_path):
"""head 模式应返回含 head 命令 + run_command 指引的 markdown。"""
f = tmp_path / "sample.txt"
f.write_text("line1\nline2\n")
r = ReadFileTool().execute(filepath=str(f), head=True, max_lines=10)
assert "head -n 10" in r
assert "run_command" in r
def test_read_file_tail_returns_command_prompt(tmp_path):
f = tmp_path / "sample.txt"
f.write_text("line1\nline2\n")
r = ReadFileTool().execute(filepath=str(f), tail=True, max_lines=20)
assert "tail -n 20" in r
assert "run_command" in r
def test_read_file_range_returns_sed_command(tmp_path):
f = tmp_path / "sample.txt"
f.write_text("line1\nline2\n")
r = ReadFileTool().execute(filepath=str(f), 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(tmp_path):
"""max_lines 应被钳制到 500 防止超大范围。"""
f = tmp_path / "sample.txt"
f.write_text("line1\n")
r = ReadFileTool().execute(filepath=str(f), 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)