Files
blackbird/aibox/tests/test_b_tools.py
T
qing d1f780e381 feat(aibox): verify SOCKS5 connectivity before saving proxy config
- set_proxy 先做完整 SOCKS5 握手 + generate_204 探测(trust_env=False,超时 4s/6s),验证失败不落盘并按类型归因提示(auth/timeout/connect/missing_dependency/probe_failed)

- 异常类型优先分类,消息关键词兜底:Windows 本地化错误消息不影响归因

- 新增 skip_check 逃生口(代理未启动的预配置场景);禁用代理不验证;password_env 指向空环境变量时提前精准提示

- 测试:+9 用例(mock 网络 + 真实异常类型),全量 383 passed 零回归
2026-08-18 07:44:17 +08:00

594 lines
19 KiB
Python

"""B 批工具单元测试
覆盖 12 个 B 批工具的核心接口与执行逻辑:
- command_tool : CommandLineTool
- skill_tools : SearchSkills/ReloadSkills/InstallSkill/CreateSkill
- db_tools : MemoryTool/MemoTool
- prompt_tools : UpdateIdentity/UpdateSoul/SaveMemoryAndEnd
- proxy_tools : SetProxy/GetProxy
- misc_tools : AskUserTool
工具大多需注入子系统,用真实子系统实例 + tmp_path 隔离。
"""
import queue
from datetime import datetime, timedelta
import pytest
from common import ConfigManager, constants
from subsystems import (
MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
SubprocessCommandExecutor,
)
from tools import (
CommandLineTool,
SearchSkillsTool, ReloadSkillsTool, InstallSkillTool, CreateSkillTool,
MemoryTool, MemoTool,
UpdateIdentityTool, UpdateSoulTool, SaveMemoryAndEndConversationTool,
SetProxyTool, GetProxyTool,
AskUserTool,
)
from tools.base import Tool
from tools.registry import ToolRegistry
# ==================== 公共 fixture ====================
@pytest.fixture(autouse=True)
def use_tmp_workspace(tmp_path, monkeypatch):
"""每个测试把 WORKSPACE_DIR/SAVE_DIR 重定向到临时目录,避免污染 ~/.aibox。"""
ws = tmp_path / "ws"
ws.mkdir()
save = tmp_path / "save"
save.mkdir()
monkeypatch.setattr(type(constants), "WORKSPACE_DIR", property(lambda self: str(ws)))
monkeypatch.setattr(type(constants), "SAVE_DIR", property(lambda self: str(save)))
@pytest.fixture
def mem_db(tmp_path):
return MemoryDatabase(db_path=str(tmp_path / "mem.db"))
@pytest.fixture
def memo_db(tmp_path):
return MemoDatabase(db_path=str(tmp_path / "memo.db"))
@pytest.fixture
def prompt_mgr(tmp_path, monkeypatch):
"""独立 PromptManager(重置单例 + 临时 prompts 目录)。"""
prompts_dir = tmp_path / "prompts"
prompts_dir.mkdir()
PromptManager._instance = None
PromptManager._prompts = []
PromptManager._prompts_dir = None
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
@pytest.fixture
def skill_mgr(tmp_path, monkeypatch):
"""独立 SkillManager + 临时技能目录。"""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
monkeypatch.setattr(type(constants), "SKILLS_DIR", property(lambda self: str(skills_dir)))
return SkillManager()
# ==================== 通用接口契约(参数化)====================
B_TOOL_CLASSES = [
CommandLineTool, SearchSkillsTool, ReloadSkillsTool, InstallSkillTool,
CreateSkillTool, MemoryTool, MemoTool, UpdateIdentityTool, UpdateSoulTool,
SaveMemoryAndEndConversationTool, SetProxyTool, GetProxyTool, AskUserTool,
]
@pytest.mark.parametrize("cls", B_TOOL_CLASSES, ids=lambda c: c.__name__)
def test_b_tool_is_tool_subclass(cls):
assert issubclass(cls, Tool)
def test_b_tool_names_unique():
"""所有 B 批工具的 get_name() 应唯一(需先实例化,部分需注入)。"""
# 这里只验证类名能取到非空 name(无法统一实例化,跳过唯一性全检)
pass
# ==================== CommandLineTool ====================
def test_command_tool_interface():
t = CommandLineTool()
assert t.get_name() == "run_command"
assert "执行" in t.get_description() or "命令" in t.get_description()
params = t.get_parameters()
assert "command" in params["properties"]
assert "command" in params["required"]
def test_command_tool_execute_echo():
t = CommandLineTool()
r = t.execute(command="echo b_test_123", timeout=5)
assert "b_test_123" in r
def test_command_tool_missing_command_returns_error():
t = CommandLineTool()
r = t.execute(command="", timeout=5)
assert "❌" in r or "错误" in r
def test_command_tool_streaming_emits_complete():
t = CommandLineTool()
q = queue.Queue()
t.execute_streaming(q, command="echo hi", timeout=5)
msgs = []
while not q.empty():
msgs.append(q.get_nowait())
assert any(m[0] == "complete" for m in msgs)
# ==================== SearchSkillsTool ====================
def test_search_skills_interface(skill_mgr):
t = SearchSkillsTool(skill_mgr)
assert t.get_name() == "search_skills"
params = t.get_parameters()
assert "keyword" in params["properties"]
assert "keyword" in params["required"]
def test_search_skills_empty_keyword_lists_all(skill_mgr, tmp_path):
# 写入一个技能
(tmp_path / "skills" / "demo.md").write_text("# Demo\n内容", encoding="utf-8")
skill_mgr.reload()
t = SearchSkillsTool(skill_mgr)
r = t.execute(keyword="")
assert "所有可用技能" in r
assert "demo" in r
def test_search_skills_no_match(skill_mgr):
t = SearchSkillsTool(skill_mgr)
r = t.execute(keyword="不存在的技能xyz")
assert "未找到" in r
def test_search_skills_with_content(skill_mgr, tmp_path):
(tmp_path / "skills" / "weather.md").write_text("# 天气\n查询天气详情ABC", encoding="utf-8")
skill_mgr.reload()
t = SearchSkillsTool(skill_mgr)
r = t.execute(keyword="weather", show_content=True)
assert "查询天气详情ABC" in r
# ==================== ReloadSkillsTool ====================
def test_reload_skills_interface(skill_mgr):
reg = ToolRegistry()
reg.skill_manager = skill_mgr
t = ReloadSkillsTool(reg)
assert t.get_name() == "reload_skills"
r = t.execute(confirm=False)
assert "取消" in r
def test_reload_skills_confirm(skill_mgr):
reg = ToolRegistry()
reg.skill_manager = skill_mgr
t = ReloadSkillsTool(reg)
r = t.execute(confirm=True)
# 重载成功应返回技能数量信息
assert "技能" in r or "重载" in r
# ==================== InstallSkillTool ====================
def test_install_skill_interface():
t = InstallSkillTool()
assert t.get_name() == "install_skill"
assert "skill_path" in t.get_parameters()["properties"]
def test_install_skill_returns_guide():
t = InstallSkillTool()
r = t.execute()
assert "安装" in r or "步骤" in r
assert "skills" in r.lower() or ".md" in r
def test_install_skill_with_path():
t = InstallSkillTool()
r = t.execute(skill_path="/tmp/my_skill.md")
assert "my_skill" in r
# ==================== CreateSkillTool ====================
def test_create_skill_interface():
t = CreateSkillTool()
assert t.get_name() == "create_skill"
params = t.get_parameters()
assert "skill_name" in params["required"]
assert "description" in params["required"]
def test_create_skill_validation_empty_name():
t = CreateSkillTool()
r = t.execute(skill_name="", description="desc")
assert "❌" in r
def test_create_skill_validation_invalid_name():
t = CreateSkillTool()
r = t.execute(skill_name="bad name!", description="desc")
assert "❌" in r
def test_create_skill_returns_prompt():
t = CreateSkillTool()
r = t.execute(skill_name="fetch-weather", description="获取天气")
assert "fetch-weather" in r
assert "步骤" in r or "创建" in r
# ==================== MemoryTool ====================
def test_memory_tool_interface(mem_db):
t = MemoryTool(mem_db)
assert t.get_name() == "memory"
params = t.get_parameters()
assert "action" in params["required"]
def test_memory_save(mem_db):
t = MemoryTool(mem_db)
r = t.execute(action="save", summary="测试记忆内容", topic="测试主题")
assert "记忆已保存" in r
def test_memory_search(mem_db):
t = MemoryTool(mem_db)
t.execute(action="save", summary="learn Python basics", topic="Python")
r = t.execute(action="search", query="Python")
assert "Python" in r
def test_memory_recent_empty(mem_db):
t = MemoryTool(mem_db)
r = t.execute(action="recent")
assert "没有" in r
def test_memory_unknown_action(mem_db):
t = MemoryTool(mem_db)
r = t.execute(action="invalid")
assert "❌" in r
def test_memory_delete_requires_confirm(mem_db):
t = MemoryTool(mem_db)
mid = mem_db.add_memory(topic="t", summary="s")
r = t.execute(action="delete", memory_id=mid, confirm=False)
assert "❌" in r
# ==================== MemoTool ====================
def test_memo_tool_interface(memo_db):
t = MemoTool(memo_db)
assert t.get_name() == "memo"
assert "action" in t.get_parameters()["required"]
def test_memo_add(memo_db):
t = MemoTool(memo_db)
r = t.execute(action="add", title="测试任务", reminder_minutes=30)
assert "任务已创建" in r
def test_memo_add_requires_title(memo_db):
t = MemoTool(memo_db)
r = t.execute(action="add")
assert "❌" in r
def test_memo_list_empty(memo_db):
t = MemoTool(memo_db)
r = t.execute(action="list")
assert "没有" in r
def test_memo_stats(memo_db):
t = MemoTool(memo_db)
r = t.execute(action="stats")
assert "统计" in r
def test_memo_parse_reminder_time_plus_format(memo_db):
"""_parse_reminder_time 应解析 +30m 格式。"""
t = MemoTool(memo_db)
result = t._parse_reminder_time("+30m")
assert result is not None
# 应在约 30 分钟后
dt = datetime.strptime(result, constants.DATETIME_FORMAT)
delta = dt - datetime.now()
assert timedelta(minutes=29) < delta < timedelta(minutes=31)
def test_memo_parse_reminder_time_int(memo_db):
t = MemoTool(memo_db)
result = t._parse_reminder_time(60)
assert result is not None
# ==================== UpdateIdentityTool ====================
def test_update_identity_interface(prompt_mgr):
t = UpdateIdentityTool(prompt_mgr)
assert t.get_name() == "update_identity"
assert "content" in t.get_parameters()["required"]
def test_update_identity_success(prompt_mgr):
t = UpdateIdentityTool(prompt_mgr)
r = t.execute(content="测试用户身份", confirm=True)
assert "✅" in r
assert "测试用户身份" in prompt_mgr.get_identity()
def test_update_identity_no_content(prompt_mgr):
t = UpdateIdentityTool(prompt_mgr)
r = t.execute(content=None, confirm=True)
assert "❌" in r
def test_update_identity_cancelled(prompt_mgr):
t = UpdateIdentityTool(prompt_mgr)
r = t.execute(content="x", confirm=False)
assert "❌" in r
# ==================== UpdateSoulTool ====================
def test_update_soul_interface(prompt_mgr):
t = UpdateSoulTool(prompt_mgr)
assert t.get_name() == "update_soul"
def test_update_soul_success(prompt_mgr):
t = UpdateSoulTool(prompt_mgr)
r = t.execute(content="我是测试灵魂", confirm=True)
assert "✅" in r
assert "我是测试灵魂" in prompt_mgr.get_soul()
# ==================== SaveMemoryAndEndConversationTool ====================
def test_save_memory_end_interface(prompt_mgr, mem_db):
cb_called = []
def cb(reason, summary):
cb_called.append((reason, summary))
t = SaveMemoryAndEndConversationTool(cb, mem_db, prompt_mgr)
assert t.get_name() == "save_memory_and_end_conversation"
def test_save_memory_end_short_summary_rejected(prompt_mgr, mem_db):
t = SaveMemoryAndEndConversationTool(lambda *a: None, mem_db, prompt_mgr)
r = t.execute(summary="短", current_status="状态")
assert "❌" in r
def test_save_memory_end_success(prompt_mgr, mem_db):
cb_called = []
t = SaveMemoryAndEndConversationTool(
lambda reason, summary: cb_called.append((reason, summary)),
mem_db, prompt_mgr
)
long_summary = "这是一段足够长的对话总结内容用于测试保存记忆功能,必须超过五十个字符才能通过校验,所以这里再加一些内容确保长度足够。"
r = t.execute(summary=long_summary, topic="测试", current_status="当前状态OK")
assert "✅" in r
assert len(cb_called) == 1
assert cb_called[0][0] == "话题讨论完成"
# ==================== SetProxyTool / GetProxyTool ====================
def test_set_proxy_interface():
assert SetProxyTool().get_name() == "set_proxy"
params = SetProxyTool().get_parameters()
assert "host" in params["required"]
assert "port" in params["required"]
def test_get_proxy_interface():
assert GetProxyTool().get_name() == "get_proxy"
def test_get_proxy_when_disabled():
t = GetProxyTool()
r = t.execute()
# 默认未启用代理
assert "未启用" in r or "启用" in r
# ---------- SetProxyTool 连通性验证(设置前验证,失败不落盘) ----------
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
class _FakeResp:
def __init__(self, status_code):
self.status_code = status_code
def _fake_session_factory(exc=None, resp=None):
"""构造替代 requests.Session 的假类:get() 抛异常或返回假响应"""
calls = []
class _FakeSession:
def __init__(self):
self.trust_env = True
def get(self, *args, **kwargs):
calls.append((args, kwargs))
if exc:
raise exc
return resp
_FakeSession.calls = calls
return _FakeSession
def test_check_connectivity_ok(monkeypatch):
fake = _fake_session_factory(resp=_FakeResp(204))
monkeypatch.setattr("tools.proxy_tools.requests.Session", fake)
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert ok and kind == ""
# trust_env 必须关掉:排除系统 http_proxy 环境变量干扰
assert fake.calls, "应发出探测请求"
def test_check_connectivity_classification(monkeypatch, tmp_path):
"""异常类型/消息 → 错误类型分类(与系统语言无关)"""
import requests as requests_lib
cases = [
# Windows 本地化的连接拒绝消息:靠异常类型而非英文关键词分类
(requests_lib.exceptions.ConnectionError(
"[WinError 10061] 由于目标计算机积极拒绝,无法连接。"), "connect_failed"),
# ConnectTimeout 同时继承 ConnectionError/Timeout,必须按 Timeout 归类
(requests_lib.exceptions.ConnectTimeout("connection timeout"), "timeout"),
(requests_lib.exceptions.ConnectionError(
"SOCKS5 authentication failed"), "auth_failed"),
(requests_lib.exceptions.ConnectionError(
"Missing dependencies for SOCKS support."), "missing_dependency"),
(Exception("Read timed out"), "timeout"), # 兜底:非 requests 异常按消息
(Exception("Connection refused"), "connect_failed"),
(Exception("something weird"), "error"),
]
t = SetProxyTool()
for exc, expect_kind in cases:
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(exc=exc))
ok, kind, _ = t._check_connectivity("127.0.0.1", 1080, "u", "p")
assert not ok
assert kind == expect_kind, f"{exc!r} 应分类为 {expect_kind},实际 {kind}"
def test_check_connectivity_bad_status(monkeypatch):
"""探测端点返回非 200/204 → probe_failed"""
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(resp=_FakeResp(502)))
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert not ok and kind == "probe_failed"
def test_set_proxy_rejected_when_unreachable(fresh_config, monkeypatch):
"""验证失败:返回 ❌ 且配置不落盘"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (False, "connect_failed", "Connection refused"))
t = SetProxyTool()
r = t.execute(host="10.255.255.1", port=1080)
assert "❌" in r
assert "未保存" in r
# 配置未写入:代理仍未启用
assert fresh_config().get_proxy() is None
def test_set_proxy_saved_when_check_passes(fresh_config, monkeypatch):
"""验证通过:配置保存且结果带连通性详情"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (True, "", "探测成功 HTTP 204"))
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080)
assert "✅" in r
assert "探测成功" in r
proxies = fresh_config().get_proxy()
assert proxies and "127.0.0.1:1080" in proxies["http"]
def test_set_proxy_skip_check_bypasses_validation(fresh_config, monkeypatch):
"""skip_check=True:不验证直接保存(代理未启动的预配置场景)"""
called = []
def _no_call(self, *a, **kw):
called.append(1)
raise AssertionError("skip_check=True 时不应调用验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, skip_check=True)
assert "✅" in r
assert not called
assert fresh_config().get_proxy() is not None
def test_set_proxy_disabled_skips_check(fresh_config, monkeypatch):
"""enabled=False:禁用代理无需验证"""
def _no_call(self, *a, **kw):
raise AssertionError("禁用代理不应触发验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, enabled=False)
assert "✅" in r
assert fresh_config().get_proxy() is None # 禁用态 get_proxy 返回 None
def test_set_proxy_empty_env_hint(fresh_config, monkeypatch):
"""username + password_env 指向未设环境变量:精准提示而非裸 auth 失败"""
monkeypatch.delenv("AIBOX_TEST_SOCKS_PW", raising=False)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080,
username="u1", password_env="AIBOX_TEST_SOCKS_PW")
assert "❌" in r
assert "环境变量" in r
assert "未保存" in r
assert fresh_config().get_proxy() is None
# ==================== AskUserTool ====================
def test_ask_user_interface():
t = AskUserTool()
assert t.get_name() == "ask_user"
def test_ask_user_returns_question():
t = AskUserTool()
r = t.execute(question="今天天气如何?")
assert "今天天气如何" in r
assert "询问用户" in r