"""`dwg_review.mcp_client` 单元测试。 真实 `McpClient` + 打桩的 `_send_frame`(绝不真连 8080)。 """ import socket import pytest from dwg_review import config from dwg_review.mcp_client import ( McpClient, McpError, McpToolError, result_images, result_json, result_texts, ) from tests.fakes import err, ok_mixed, ok_text, rpc_error def make_client(monkeypatch, response): """构造一个 McpClient,其 _send_frame 被替换为回放 response。""" client = McpClient() sent = [] def fake_send(payload, timeout=None): sent.append(payload) return response(payload) if callable(response) else response monkeypatch.setattr(client, "_send_frame", fake_send) return client, sent # --------------------------------------------------------------------------- # 响应辅助函数 # --------------------------------------------------------------------------- def test_result_texts_extracts_only_text_blocks(): resp = ok_mixed(["hello", "world"], [("AAA", "image/png")]) assert result_texts(resp) == ["hello", "world"] def test_result_images_extracts_data_and_mime(): resp = ok_mixed([], [("AAA", "image/png"), ("BBB", "image/jpeg")]) assert result_images(resp) == [("AAA", "image/png"), ("BBB", "image/jpeg")] def test_result_images_defaults_mime_to_png(): resp = {"result": {"content": [{"type": "image", "data": "X"}]}} assert result_images(resp) == [("X", "image/png")] def test_result_json_parses_json_text(): assert result_json(ok_text({"a": 1})) == {"a": 1} def test_result_json_returns_raw_text_when_not_json(): assert result_json(ok_text("未发现明显问题")) == "未发现明显问题" def test_result_json_returns_default_when_no_text(): assert result_json({"result": {"content": []}}, "D") == "D" assert result_json(None, "D") == "D" def test_result_texts_tolerates_malformed_response(): assert result_texts(None) == [] assert result_texts({}) == [] # --------------------------------------------------------------------------- # 请求 / 响应 # --------------------------------------------------------------------------- def test_call_tool_returns_normalized_response(monkeypatch): client, sent = make_client(monkeypatch, ok_text({"total": 3})) resp = client.call_tool("query_entities", {"window": {}}) assert result_json(resp) == {"total": 3} assert sent[0]["method"] == "tools/call" assert sent[0]["params"] == {"name": "query_entities", "arguments": {"window": {}}} assert sent[0]["jsonrpc"] == "2.0" def test_call_tool_arguments_default_to_empty_object(monkeypatch): client, sent = make_client(monkeypatch, ok_text({})) client.call_tool("get_layers") assert sent[0]["params"]["arguments"] == {} def test_request_ids_increment(monkeypatch): client, sent = make_client(monkeypatch, ok_text({})) client.call_tool("a") client.call_tool("b") assert [p["id"] for p in sent] == [1, 2] assert client._next_id == 3 def test_rpc_error_raises_mcp_error(monkeypatch): client, _ = make_client(monkeypatch, rpc_error(-32602, "Unknown tool: x")) with pytest.raises(McpError) as ei: client.call_tool("x") assert ei.value.code == -32602 assert "Unknown tool" in ei.value.message def test_is_error_raises_mcp_tool_error(monkeypatch): client, _ = make_client(monkeypatch, err("工具炸了")) with pytest.raises(McpToolError) as ei: client.call_tool("x") assert ei.value.message == "工具炸了" def test_is_error_without_text_uses_fallback_message(monkeypatch): resp = {"result": {"content": [], "isError": True}} client, _ = make_client(monkeypatch, resp) with pytest.raises(McpToolError) as ei: client.call_tool("x") assert ei.value.message == "tool error" def test_raw_rpc_does_not_normalize_is_error(monkeypatch): client, _ = make_client(monkeypatch, err("boom")) out = client.raw_rpc("tools/call", {"name": "x"}) assert out["result"]["isError"] is True # 未抛异常,原样透传 def test_raw_rpc_does_not_normalize_rpc_error(monkeypatch): client, _ = make_client(monkeypatch, rpc_error(-32700, "parse error")) out = client.raw_rpc("tools/call", {"name": "x"}) assert out["error"]["code"] == -32700 def test_normalize_passthrough_on_success(): resp = ok_text({}) assert McpClient._normalize(resp) is resp # --------------------------------------------------------------------------- # 重试 # --------------------------------------------------------------------------- def test_retries_on_oserror(monkeypatch): monkeypatch.setattr("dwg_review.mcp_client.time.sleep", lambda *_: None) def boom(payload, timeout=None): raise OSError("connection refused") client = McpClient() monkeypatch.setattr(client, "_send_frame", boom) with pytest.raises(McpError) as ei: client.call_tool("x", retries=2) assert ei.value.code == -32603 def test_retry_count_matches_config(monkeypatch): monkeypatch.setattr("dwg_review.mcp_client.time.sleep", lambda *_: None) attempts = {"n": 0} def boom(payload, timeout=None): attempts["n"] += 1 raise OSError("nope") client = McpClient() monkeypatch.setattr(client, "_send_frame", boom) with pytest.raises(McpError): client.call_tool("x", retries=2) assert attempts["n"] == 3 # 1 次 + 2 次重试 def test_write_tool_never_retries(monkeypatch): monkeypatch.setattr("dwg_review.mcp_client.time.sleep", lambda *_: None) attempts = {"n": 0} def boom(payload, timeout=None): attempts["n"] += 1 raise OSError("nope") client = McpClient() monkeypatch.setattr(client, "_send_frame", boom) with pytest.raises(McpError): client.call_tool("delete_entities", {"handles": []}, retries=5, is_write=True) assert attempts["n"] == 1 # 写类工具强制不重试 def test_protocol_error_not_retried(monkeypatch): attempts = {"n": 0} def respond(payload, timeout=None): attempts["n"] += 1 return rpc_error(-32602, "bad") client = McpClient() monkeypatch.setattr(client, "_send_frame", respond) with pytest.raises(McpError): client.call_tool("x", retries=3) assert attempts["n"] == 1 # --------------------------------------------------------------------------- # 工具表缓存 / 帧大小 / 可用性 # --------------------------------------------------------------------------- def test_list_tools_caches(monkeypatch): payload = {"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "a"}]}} client, sent = make_client(monkeypatch, payload) assert client.list_tools() == [{"name": "a"}] assert client.list_tools() == [{"name": "a"}] assert len(sent) == 1 # 第二次走缓存 client.list_tools(use_cache=False) assert len(sent) == 2 def test_send_frame_rejects_oversize_request(monkeypatch): monkeypatch.setattr(config, "MAX_FRAME_BYTES", 16) client = McpClient() with pytest.raises(McpError) as ei: client._send_frame({"blob": "x" * 100}) assert ei.value.code == -32600 def test_is_available_true_when_connect_ok(monkeypatch): client = McpClient() monkeypatch.setattr(client, "_connect", lambda timeout=None: type("S", (), {"close": lambda self: None})()) assert client.is_available() is True def test_is_available_false_on_oserror(monkeypatch): client = McpClient() def boom(timeout=None): raise OSError("refused") monkeypatch.setattr(client, "_connect", boom) assert client.is_available() is False def test_timeout_error_wrapped_as_mcp_error(monkeypatch): monkeypatch.setattr("dwg_review.mcp_client.time.sleep", lambda *_: None) def boom(payload, timeout=None): raise socket.timeout("timed out") client = McpClient() monkeypatch.setattr(client, "_send_frame", boom) with pytest.raises(McpError) as ei: client.call_tool("x", retries=0) assert ei.value.code == -32603