Files
cad-agent/tests/test_mcp_client.py
T
gjm daccc734bf test(review): 把 5 处既有瑕疵锁成 *_known_gap 用例
让既有瑕疵可见,而不是被当成正确行为;改 1~3(确定性检查前置/结论结构化/
证据交叉校验)时可据此判断行为是否被改变。

- bbox_to_window 含非数值 → ValueError(上游只判长度)
- summarize / extract_issues 硬下标 f['frame'] → KeyError
- annotate 的 created 无去重(同 handle 产生重复批注)
- extract_issues 单行 ```json[...]``` 围栏剥离失败 → []
- result_json 文本 'null' → None(与「无 text 返回 default」语义易混)
2026-10-14 19:09:56 +08:00

250 lines
8.1 KiB
Python

"""`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_json_null_text_known_gap():
"""已知瑕疵:文本 'null' 是合法 JSON,解析为 None,与「无 text 返回 default」语义易混。"""
assert result_json(ok_text("null"), "D") is None
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