"""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 "