让既有瑕疵可见,而不是被当成正确行为;改 1~3(确定性检查前置/结论结构化/ 证据交叉校验)时可据此判断行为是否被改变。 - bbox_to_window 含非数值 → ValueError(上游只判长度) - summarize / extract_issues 硬下标 f['frame'] → KeyError - annotate 的 created 无去重(同 handle 产生重复批注) - extract_issues 单行 ```json[...]``` 围栏剥离失败 → [] - result_json 文本 'null' → None(与「无 text 返回 default」语义易混)
250 lines
8.1 KiB
Python
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
|