test(review): 新增 pytest 离线回归网,锁定 dwg_review 既有行为
在改动审查流水线(确定性检查前置 / 结论结构化 / 证据交叉校验)之前, 先立一张离线、确定性、秒级的回归网。 - tests/fakes.py:假 LLM + 假 MCP 基座 + 响应构造器(ok_text/ok_image/err) + make_ctx/make_evidence,绝不触网、不读密钥 - 10 个模块共 134 用例: mcp_client / llm_client / cad_tools / checklist / config / planning / evidence / review / report / annotator - pytest.ini(testpaths=tests)+ requirements-dev.txt + 根 conftest.py (sys.path + RENDER_WAIT=0 / REPORT_DIR=tmp fixture)
This commit is contained in:
1 parent
8c07c7cad6
commit
0656397a68
15 files changed
+1710
No files matched your search
@@ -0,0 +1,244 @@
|
||||
"""`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
|
||||
Reference in new issue
Block a user