chore: 删除 5 个遗留调试脚本并同步文档引用 #12
No files matched your search
+31
@@ -0,0 +1,31 @@
|
||||
"""pytest 全局配置(位于仓库根,保证 `import dwg_review` 可用)。
|
||||
|
||||
注意:`dwg_review.config` 的常量在**导入期**就已定值,改 `os.environ` 不会生效,
|
||||
所以测试里一律用 `monkeypatch.setattr(config, "XXX", ...)` 覆盖模块属性。
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = pathlib.Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_render_wait(monkeypatch):
|
||||
"""消除 collect_frame 里每个图框的 RENDER_WAIT 等待(单元测试不需要真实渲染)。"""
|
||||
from dwg_review import config
|
||||
|
||||
monkeypatch.setattr(config, "RENDER_WAIT", 0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_reports(tmp_path, monkeypatch):
|
||||
"""把报告输出目录指向临时目录,避免测试污染仓库的 reports/。"""
|
||||
from dwg_review import config
|
||||
|
||||
monkeypatch.setattr(config, "REPORT_DIR", tmp_path)
|
||||
return tmp_path
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
# 只扫描 tests/:既覆盖全部单元测试,又避开根目录的 verify_*.py(需 CAD 在线的端到端冒烟)
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
@@ -0,0 +1,4 @@
|
||||
# dwg_review 回归测试网(离线快层)的开发依赖
|
||||
# 安装:pip install -r requirements-dev.txt
|
||||
# 运行:python -m pytest
|
||||
pytest>=8.3
|
||||
@@ -0,0 +1,5 @@
|
||||
"""dwg_review 回归测试网(离线快层)。
|
||||
|
||||
与仓库根目录的 `verify_*.py` 不同:这里全部使用假 LLM + 假 MCP 基座,
|
||||
不需要 CAD 在线、不联网、秒级完成,用于锁定流水线的既有行为。
|
||||
"""
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
"""离线测试假件:假 LLM、假 MCP 基座、响应构造器。
|
||||
|
||||
设计约束(踩过的坑,务必遵守):
|
||||
- 绝不构造真实的 `LlmClient`——没有 API Key 时它的 `__init__` 直接抛 `LlmError`。
|
||||
- 绝不无参构造 `CadTools()` / `ReviewContext.create()`——二者会立刻调用
|
||||
`list_tools()` 去连 127.0.0.1:8080。一律用 `make_ctx()` 直接拼装。
|
||||
|
||||
本文件名不带 `test_` 前缀,故不会被 pytest 收集为用例。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from dwg_review.cad_tools import CadTools
|
||||
from dwg_review.checklist import DEFAULT_CHECKLIST
|
||||
from dwg_review.context import ReviewContext
|
||||
from dwg_review.mcp_client import McpClient, McpError, McpToolError
|
||||
|
||||
__all__ = [
|
||||
"FakeLlmClient",
|
||||
"FakeMcpClient",
|
||||
"McpError",
|
||||
"McpToolError",
|
||||
"STANDARD_TOOLS",
|
||||
"err",
|
||||
"frame",
|
||||
"llm_calls",
|
||||
"llm_text",
|
||||
"make_ctx",
|
||||
"make_evidence",
|
||||
"ok_image",
|
||||
"ok_mixed",
|
||||
"ok_text",
|
||||
"rpc_error",
|
||||
"tool_schema",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI 风格响应对象(模仿 openai SDK 的 choices[0].message 结构)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _Fn:
|
||||
"""tool_call.function"""
|
||||
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class _TC:
|
||||
"""一个 tool_call。"""
|
||||
|
||||
def __init__(self, call_id, name, args):
|
||||
self.id = call_id
|
||||
self.type = "function"
|
||||
self.function = _Fn(
|
||||
name, args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
|
||||
)
|
||||
|
||||
|
||||
class _Msg:
|
||||
"""assistant 消息。"""
|
||||
|
||||
def __init__(self, content=None, tool_calls=None):
|
||||
self.role = "assistant"
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
|
||||
|
||||
class _Choice:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, message):
|
||||
self.choices = [_Choice(message)]
|
||||
|
||||
|
||||
def llm_text(text):
|
||||
"""构造一个「纯文本回复」的 LLM 响应。"""
|
||||
return _Resp(_Msg(content=text))
|
||||
|
||||
|
||||
def llm_calls(pairs, content=None):
|
||||
"""构造一个「请求调用工具」的 LLM 响应。
|
||||
|
||||
pairs: [(工具名, 参数字典), ...]
|
||||
content: 可选的附带文本。
|
||||
"""
|
||||
tool_calls = [
|
||||
_TC(str(i + 1), name, args) for i, (name, args) in enumerate(pairs)
|
||||
]
|
||||
return _Resp(_Msg(content=content, tool_calls=tool_calls))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 假 LLM 客户端
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeLlmClient:
|
||||
"""按脚本回放 LLM 响应,并记录每次调用。
|
||||
|
||||
- `script` :`chat()` 依次弹出的响应队列;弹空后回落到 `text`。
|
||||
- `text` :`chat()` / `chat_text()` 的兜底文本。
|
||||
- `text_script` :`chat_text()` 专用队列(便于同时喂 summarize 与 extract_issues)。
|
||||
"""
|
||||
|
||||
def __init__(self, script=None, text="", text_script=None, model="fake-model"):
|
||||
self.script = list(script or [])
|
||||
self.text = text
|
||||
self.text_script = list(text_script or [])
|
||||
self.model = model
|
||||
self.calls = []
|
||||
|
||||
def chat(self, messages, *, tools=None, timeout=None, retries=None):
|
||||
self.calls.append({"messages": list(messages), "tools": tools})
|
||||
if self.script:
|
||||
return self.script.pop(0)
|
||||
return llm_text(self.text)
|
||||
|
||||
def chat_text(self, system, user, **kwargs):
|
||||
self.calls.append({"system": system, "user": user})
|
||||
if self.text_script:
|
||||
return self.text_script.pop(0)
|
||||
return self.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-RPC / MCP 响应构造器
|
||||
# ---------------------------------------------------------------------------
|
||||
def ok_text(obj, *, req_id=1):
|
||||
"""成功响应,content 为一段文本(非字符串会先 JSON 序列化)。"""
|
||||
text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]},
|
||||
}
|
||||
|
||||
|
||||
def ok_image(b64, mime="image/png", *, req_id=1):
|
||||
"""成功响应,content 为一张图片。"""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "image", "data": b64, "mimeType": mime}]},
|
||||
}
|
||||
|
||||
|
||||
def ok_mixed(objs, images=(), *, req_id=1):
|
||||
"""成功响应,content 同时含文本与图片。"""
|
||||
content = []
|
||||
for obj in objs:
|
||||
text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
|
||||
content.append({"type": "text", "text": text})
|
||||
for b64, mime in images:
|
||||
content.append({"type": "image", "data": b64, "mimeType": mime})
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": content}}
|
||||
|
||||
|
||||
def err(text, *, req_id=1):
|
||||
"""工具执行错误:result.isError == true。"""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}], "isError": True},
|
||||
}
|
||||
|
||||
|
||||
def rpc_error(code, message, *, req_id=1):
|
||||
"""协议级错误:JSON-RPC 顶层 error。"""
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具 schema
|
||||
# ---------------------------------------------------------------------------
|
||||
def tool_schema(name, description="", properties=None, required=None):
|
||||
"""构造一条 tools/list 条目。"""
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": properties or {},
|
||||
"required": required or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
STANDARD_TOOLS = [
|
||||
tool_schema("get_drawing_info", "图纸信息"),
|
||||
tool_schema("get_layers", "图层列表"),
|
||||
tool_schema("get_layouts", "布局列表"),
|
||||
tool_schema("detect_drawing_frames", "识别图框"),
|
||||
tool_schema("zoom_to_window", "缩放到窗口"),
|
||||
tool_schema("zoom_to_entity", "缩放到实体"),
|
||||
tool_schema("query_entities", "查询图元"),
|
||||
tool_schema("get_text_content", "文字内容"),
|
||||
tool_schema("get_dimensions", "标注"),
|
||||
tool_schema("get_block_references", "块参照"),
|
||||
tool_schema("get_polylines_info", "多段线"),
|
||||
tool_schema("get_viewport_screenshot", "视口截图"),
|
||||
tool_schema("get_entity_detail", "实体详情"),
|
||||
tool_schema("find_duplicate_entities", "重复实体"),
|
||||
tool_schema("find_zero_length", "零长度实体"),
|
||||
tool_schema("add_leader_annotation", "添加引线批注"),
|
||||
tool_schema("highlight_entities", "高亮实体"),
|
||||
tool_schema("delete_entities", "删除实体"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 假 MCP 客户端
|
||||
# ---------------------------------------------------------------------------
|
||||
def _as_result(value, arguments):
|
||||
"""把注入值解释为「客户端返回值」或「异常」。"""
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
if callable(value):
|
||||
value = value(arguments)
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
# 原始 JSON-RPC 响应 → 走真实客户端的归一化(error → McpError,isError → McpToolError)
|
||||
if isinstance(value, dict) and ("result" in value or "error" in value):
|
||||
return McpClient._normalize(value)
|
||||
return value
|
||||
|
||||
|
||||
class FakeMcpClient:
|
||||
"""假 MCP 基座客户端。
|
||||
|
||||
- `tools` :tools/list 返回的工具列表(None = STANDARD_TOOLS)。
|
||||
- `responses` :工具名 → 注入值。注入值可以是
|
||||
* 原始响应 dict(用 ok_text/ok_image/err/rpc_error 构造)——按真实客户端语义归一化;
|
||||
* 异常实例(McpError/McpToolError)——直接抛出;
|
||||
* 可调用对象 f(arguments)——返回上面任意一种。
|
||||
未命中的工具默认返回 `ok_text({})`。
|
||||
"""
|
||||
|
||||
def __init__(self, tools=None, responses=None, available=True):
|
||||
self._tools = list(STANDARD_TOOLS if tools is None else tools)
|
||||
self.responses = dict(responses or {})
|
||||
self.available = available
|
||||
self.calls = [] # call_tool 调用日志
|
||||
self.raw_calls = [] # raw_rpc 调用日志
|
||||
|
||||
# -- 客户端 API --------------------------------------------------------
|
||||
def list_tools(self, *, use_cache=True):
|
||||
return list(self._tools)
|
||||
|
||||
def call_tool(self, name, arguments=None, *, timeout=None, retries=None, is_write=False):
|
||||
self.calls.append({"name": name, "arguments": arguments, "is_write": is_write})
|
||||
if name not in self.responses:
|
||||
return ok_text({})
|
||||
return _as_result(self.responses[name], arguments)
|
||||
|
||||
def raw_rpc(self, method, params=None, *, timeout=None, retries=None):
|
||||
self.raw_calls.append({"method": method, "params": params})
|
||||
if method in self.responses:
|
||||
return _as_result(self.responses[method], params)
|
||||
return {"jsonrpc": "2.0", "id": 1, "result": {}}
|
||||
|
||||
def is_available(self, timeout=1.5):
|
||||
return self.available
|
||||
|
||||
# -- 断言辅助 ----------------------------------------------------------
|
||||
def called(self, name):
|
||||
return [c for c in self.calls if c["name"] == name]
|
||||
|
||||
def called_names(self):
|
||||
return [c["name"] for c in self.calls]
|
||||
|
||||
def arguments_of(self, name):
|
||||
"""返回某工具**最后一次**调用的参数。"""
|
||||
hits = self.called(name)
|
||||
return hits[-1]["arguments"] if hits else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 组合辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_ctx(client=None, llm=None, checklist=None, **over):
|
||||
"""直接拼装一个 ReviewContext(不触碰网络、不读密钥)。"""
|
||||
client = client if client is not None else FakeMcpClient()
|
||||
params = dict(
|
||||
client=client,
|
||||
tools=CadTools(client),
|
||||
llm=llm if llm is not None else FakeLlmClient(),
|
||||
checklist=checklist if checklist is not None else DEFAULT_CHECKLIST,
|
||||
max_frames=0,
|
||||
frame_limit=200,
|
||||
max_iters=4,
|
||||
)
|
||||
params.update(over)
|
||||
return ReviewContext(**params)
|
||||
|
||||
|
||||
def frame(bbox, kind="title_block", sheet_size="A1", layer="0", confidence=0.9, **extra):
|
||||
"""构造一个图框描述(detect_drawing_frames 返回的 items 元素)。"""
|
||||
data = {
|
||||
"bbox": list(bbox),
|
||||
"kind": kind,
|
||||
"sheet_size": sheet_size,
|
||||
"layer": layer,
|
||||
"confidence": confidence,
|
||||
}
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
def make_evidence(
|
||||
index=1,
|
||||
bbox=(0.0, 0.0, 100.0, 50.0),
|
||||
image_b64=None,
|
||||
image_mime=None,
|
||||
**over,
|
||||
):
|
||||
"""构造 collect_frame 的返回值(evidence)。"""
|
||||
ev = {
|
||||
"index": index,
|
||||
"frame": frame(bbox),
|
||||
"window": {
|
||||
"min_x": float(bbox[0]),
|
||||
"min_y": float(bbox[1]),
|
||||
"max_x": float(bbox[2]),
|
||||
"max_y": float(bbox[3]),
|
||||
},
|
||||
"entities": {"total": 0, "items": []},
|
||||
"texts": {"total": 0, "items": []},
|
||||
"dimensions": {"total": 0, "items": []},
|
||||
"blocks": {"total": 0, "items": []},
|
||||
"polylines": {"total": 0, "items": []},
|
||||
"image_b64": image_b64,
|
||||
"image_mime": image_mime,
|
||||
}
|
||||
ev.update(over)
|
||||
return ev
|
||||
@@ -0,0 +1,151 @@
|
||||
"""`dwg_review.annotator` 单元测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from dwg_review.annotator import _pick_point, annotate, clear
|
||||
from dwg_review.mcp_client import McpError, McpToolError
|
||||
from tests.fakes import FakeMcpClient, make_ctx, ok_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _pick_point
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_pick_point_from_bbox_center():
|
||||
assert _pick_point({"bbox": [0, 0, 10, 20]}) == [5.0, 10.0, 0.0]
|
||||
|
||||
|
||||
def test_pick_point_from_position():
|
||||
assert _pick_point({"position": [1, 2, 3]}) == [1.0, 2.0, 3.0]
|
||||
|
||||
|
||||
def test_pick_point_from_position_without_z():
|
||||
assert _pick_point({"position": [1, 2]}) == [1.0, 2.0, 0.0]
|
||||
|
||||
|
||||
def test_pick_point_prefers_bbox_over_position():
|
||||
assert _pick_point({"bbox": [0, 0, 10, 10], "position": [99, 99]}) == [5.0, 5.0, 0.0]
|
||||
|
||||
|
||||
def test_pick_point_returns_none_when_unlocatable():
|
||||
assert _pick_point({}) is None
|
||||
assert _pick_point({"bbox": [0, 0, 1]}) is None
|
||||
assert _pick_point("not-a-dict") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# annotate
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_client(detail=None, created=None, fail_leader=False):
|
||||
responses = {"get_entity_detail": detail if detail is not None else ok_text({"bbox": [0, 0, 10, 10]})}
|
||||
if fail_leader:
|
||||
responses["add_leader_annotation"] = McpToolError("引线失败")
|
||||
else:
|
||||
responses["add_leader_annotation"] = ok_text(
|
||||
created if created is not None else {"created": [{"handle": "NEW1"}]}
|
||||
)
|
||||
responses["highlight_entities"] = ok_text({})
|
||||
return FakeMcpClient(responses=responses)
|
||||
|
||||
|
||||
def test_annotate_dry_run_does_not_call_cad():
|
||||
logs = []
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
created, highlighted = annotate(
|
||||
ctx, [{"handle": "A", "severity": "high", "message": "x"}], dry_run=True, log=logs.append
|
||||
)
|
||||
assert created == [] and highlighted == []
|
||||
assert ctx.client.calls == []
|
||||
assert any("[dry-run]" in line for line in logs)
|
||||
|
||||
|
||||
def test_annotate_skips_issues_without_handle():
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
created, highlighted = annotate(
|
||||
ctx, [{"severity": "high", "message": "无 handle"}], log=lambda *a: None
|
||||
)
|
||||
assert created == [] and highlighted == []
|
||||
assert ctx.client.calls == []
|
||||
|
||||
|
||||
def test_annotate_creates_leader_and_highlights():
|
||||
ctx = make_ctx(client=build_client())
|
||||
created, highlighted = annotate(
|
||||
ctx, [{"handle": "A", "severity": "high", "message": "重叠"}], log=lambda *a: None
|
||||
)
|
||||
assert created == ["NEW1"]
|
||||
assert highlighted == ["A"]
|
||||
names = ctx.client.called_names()
|
||||
assert "get_entity_detail" in names
|
||||
assert "add_leader_annotation" in names
|
||||
assert "highlight_entities" in names
|
||||
|
||||
|
||||
def test_annotate_leader_payload_shape():
|
||||
ctx = make_ctx(client=build_client())
|
||||
annotate(ctx, [{"handle": "A", "severity": "high", "message": "m"}], log=lambda *a: None)
|
||||
args = ctx.client.arguments_of("add_leader_annotation")
|
||||
assert args["layer"] == "AI_REVIEW"
|
||||
assert len(args["points"]) == 2
|
||||
assert args["points"][0] == [5.0, 5.0, 0.0] # 实体定位点
|
||||
assert args["text_position"] == args["points"][1]
|
||||
assert args["text"].startswith("[high]")
|
||||
|
||||
|
||||
def test_annotate_skips_when_unlocatable():
|
||||
logs = []
|
||||
ctx = make_ctx(client=build_client(detail=ok_text({})))
|
||||
created, highlighted = annotate(
|
||||
ctx, [{"handle": "A", "message": "x"}], log=logs.append
|
||||
)
|
||||
assert created == [] and highlighted == []
|
||||
assert "add_leader_annotation" not in ctx.client.called_names()
|
||||
assert any("无法定位坐标" in line for line in logs)
|
||||
|
||||
|
||||
def test_annotate_tool_error_does_not_highlight():
|
||||
logs = []
|
||||
ctx = make_ctx(client=build_client(fail_leader=True))
|
||||
created, highlighted = annotate(
|
||||
ctx, [{"handle": "A", "message": "x"}], log=logs.append
|
||||
)
|
||||
assert created == [] and highlighted == []
|
||||
assert "highlight_entities" not in ctx.client.called_names()
|
||||
assert any("批注失败" in line for line in logs)
|
||||
|
||||
|
||||
def test_annotate_handles_multiple_created_entities():
|
||||
client = build_client(created={"created": [{"handle": "N1"}, {"handle": "N2"}]})
|
||||
ctx = make_ctx(client=client)
|
||||
created, _ = annotate(ctx, [{"handle": "A", "message": "x"}], log=lambda *a: None)
|
||||
assert created == ["N1", "N2"]
|
||||
|
||||
|
||||
def test_annotate_propagates_protocol_error_on_detail():
|
||||
ctx = make_ctx(client=FakeMcpClient(responses={"get_entity_detail": McpError(-32602, "bad")}))
|
||||
# annotate 只兜 add_leader/highlight 的错;detail 的协议错误会向上冒泡(现状行为)
|
||||
with pytest.raises(McpError):
|
||||
annotate(ctx, [{"handle": "A", "message": "x"}], log=lambda *a: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_clear_deletes_and_restores():
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
clear(ctx, handles=["N1"], highlighted=["A"], log=lambda *a: None)
|
||||
assert ctx.client.arguments_of("delete_entities") == {"handles": ["N1"]}
|
||||
assert ctx.client.arguments_of("highlight_entities") == {"handles": ["A"], "restore": True}
|
||||
|
||||
|
||||
def test_clear_noop_without_arguments():
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
clear(ctx, log=lambda *a: None)
|
||||
assert ctx.client.calls == []
|
||||
|
||||
|
||||
def test_clear_swallows_tool_errors():
|
||||
logs = []
|
||||
client = FakeMcpClient(responses={"delete_entities": McpToolError("删不掉")})
|
||||
ctx = make_ctx(client=client)
|
||||
clear(ctx, handles=["N1"], log=logs.append)
|
||||
assert any("撤销失败" in line for line in logs)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""`dwg_review.cad_tools` 单元测试。"""
|
||||
|
||||
from dwg_review.cad_tools import CadTools, is_write_tool
|
||||
from dwg_review.mcp_client import result_json
|
||||
from tests.fakes import FakeMcpClient, ok_text, tool_schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 写类工具判定
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_write_prefixes_detected():
|
||||
for name in ("add_leader_annotation", "delete_entities", "draw_line", "set_layer"):
|
||||
assert is_write_tool(name) is True
|
||||
|
||||
|
||||
def test_write_exact_names_detected():
|
||||
assert is_write_tool("set_entity_properties") is True
|
||||
assert is_write_tool("highlight_entities") is True
|
||||
|
||||
|
||||
def test_readonly_tools_are_not_write():
|
||||
for name in ("query_entities", "get_text_content", "zoom_to_window", "find_zero_length"):
|
||||
assert is_write_tool(name) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 注册表
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_registry_built_from_client():
|
||||
client = FakeMcpClient()
|
||||
tools = CadTools(client)
|
||||
assert "query_entities" in tools.names
|
||||
assert tools.has("get_viewport_screenshot") is True
|
||||
assert tools.has("__nope__") is False
|
||||
assert tools.names == sorted(tools.names)
|
||||
|
||||
|
||||
def test_describe_returns_tool_dict():
|
||||
client = FakeMcpClient()
|
||||
tools = CadTools(client)
|
||||
desc = tools.describe("query_entities")
|
||||
assert desc["name"] == "query_entities"
|
||||
assert "inputSchema" in desc
|
||||
|
||||
|
||||
def test_refresh_picks_up_new_tools():
|
||||
client = FakeMcpClient(tools=[tool_schema("a")])
|
||||
tools = CadTools(client)
|
||||
assert tools.names == ["a"]
|
||||
client._tools = [tool_schema("a"), tool_schema("b")]
|
||||
tools.refresh()
|
||||
assert tools.names == ["a", "b"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# openai_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_openai_tools_format():
|
||||
client = FakeMcpClient(tools=[tool_schema("query_entities", "查询", {"window": {"type": "object"}})])
|
||||
entry = CadTools(client).openai_tools()[0]
|
||||
assert entry["type"] == "function"
|
||||
assert entry["function"]["name"] == "query_entities"
|
||||
assert entry["function"]["description"] == "查询"
|
||||
assert entry["function"]["parameters"]["properties"] == {"window": {"type": "object"}}
|
||||
|
||||
|
||||
def test_openai_tools_defaults_schema_when_missing():
|
||||
client = FakeMcpClient(tools=[{"name": "bare", "description": ""}])
|
||||
entry = CadTools(client).openai_tools()[0]
|
||||
assert entry["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_openai_tools_names_filter():
|
||||
client = FakeMcpClient()
|
||||
names = CadTools(client).openai_tools(names={"query_entities", "get_layers"})
|
||||
assert sorted(n["function"]["name"] for n in names) == ["get_layers", "query_entities"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# call
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_call_returns_normalized_response():
|
||||
client = FakeMcpClient(responses={"query_entities": ok_text({"total": 7})})
|
||||
tools = CadTools(client)
|
||||
assert result_json(tools.call("query_entities", {"window": {}})) == {"total": 7}
|
||||
assert client.calls[-1]["name"] == "query_entities"
|
||||
assert client.calls[-1]["arguments"] == {"window": {}}
|
||||
|
||||
|
||||
def test_call_marks_write_tools():
|
||||
client = FakeMcpClient()
|
||||
tools = CadTools(client)
|
||||
tools.call("add_leader_annotation", {"text": "x"})
|
||||
tools.call("query_entities", {"window": {}})
|
||||
assert client.calls[0]["is_write"] is True
|
||||
assert client.calls[1]["is_write"] is False
|
||||
|
||||
|
||||
def test_call_explicit_is_write_overrides_inference():
|
||||
client = FakeMcpClient()
|
||||
CadTools(client).call("query_entities", {}, is_write=True)
|
||||
assert client.calls[-1]["is_write"] is True
|
||||
@@ -0,0 +1,72 @@
|
||||
"""`dwg_review.checklist` 单元测试。"""
|
||||
|
||||
import json
|
||||
|
||||
from dwg_review.checklist import (
|
||||
DEFAULT_CHECKLIST,
|
||||
build_system_prompt,
|
||||
checklist_summary,
|
||||
load_checklist,
|
||||
)
|
||||
|
||||
|
||||
def test_load_valid_checklist(tmp_path):
|
||||
data = {
|
||||
"name": "自定义清单",
|
||||
"categories": [{"title": "类别A", "severity": "high", "checks": ["检查1"]}],
|
||||
}
|
||||
path = tmp_path / "cl.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
loaded = load_checklist(path)
|
||||
assert loaded["name"] == "自定义清单"
|
||||
assert loaded["categories"][0]["title"] == "类别A"
|
||||
|
||||
|
||||
def test_load_missing_file_falls_back(tmp_path, capsys):
|
||||
loaded = load_checklist(tmp_path / "nope.json")
|
||||
assert loaded is DEFAULT_CHECKLIST
|
||||
assert "无法加载审查清单" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_load_malformed_structure_falls_back(tmp_path, capsys):
|
||||
path = tmp_path / "cl.json"
|
||||
path.write_text(json.dumps({"name": "缺 categories"}), encoding="utf-8")
|
||||
assert load_checklist(path) is DEFAULT_CHECKLIST
|
||||
assert "结构异常" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_load_invalid_json_falls_back(tmp_path, capsys):
|
||||
path = tmp_path / "cl.json"
|
||||
path.write_text("{ not json", encoding="utf-8")
|
||||
assert load_checklist(path) is DEFAULT_CHECKLIST
|
||||
assert "无法加载审查清单" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_load_empty_categories_falls_back(tmp_path):
|
||||
path = tmp_path / "cl.json"
|
||||
path.write_text(json.dumps({"categories": []}), encoding="utf-8")
|
||||
assert load_checklist(path) is DEFAULT_CHECKLIST
|
||||
|
||||
|
||||
def test_build_system_prompt_includes_categories_and_checks():
|
||||
prompt = build_system_prompt(DEFAULT_CHECKLIST)
|
||||
assert DEFAULT_CHECKLIST["name"] in prompt
|
||||
for cat in DEFAULT_CHECKLIST["categories"]:
|
||||
assert cat["title"] in prompt
|
||||
assert f"严重度:{cat['severity']}" in prompt
|
||||
for chk in cat["checks"]:
|
||||
assert chk in prompt
|
||||
|
||||
|
||||
def test_build_system_prompt_tool_hint_toggle():
|
||||
with_tools = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=True)
|
||||
without = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=False)
|
||||
assert "只读" in with_tools and "取证工具" in with_tools
|
||||
assert "只读" not in without
|
||||
|
||||
|
||||
def test_checklist_summary_mentions_name_and_count():
|
||||
summary = checklist_summary(DEFAULT_CHECKLIST)
|
||||
assert DEFAULT_CHECKLIST["name"] in summary
|
||||
assert str(len(DEFAULT_CHECKLIST["categories"])) in summary
|
||||
assert DEFAULT_CHECKLIST["categories"][0]["title"] in summary
|
||||
@@ -0,0 +1,68 @@
|
||||
"""`dwg_review.config` 单元测试(只验证纯函数与常量类型,不依赖真实环境变量)。"""
|
||||
|
||||
import os
|
||||
|
||||
from dwg_review import config
|
||||
|
||||
|
||||
def test_resolve_path_relative_uses_project_root():
|
||||
assert config._resolve_path("reports") == config.PROJECT_ROOT / "reports"
|
||||
|
||||
|
||||
def test_resolve_path_absolute_unchanged(tmp_path):
|
||||
assert config._resolve_path(str(tmp_path)) == tmp_path
|
||||
|
||||
|
||||
def test_project_root_is_repo_root():
|
||||
assert (config.PROJECT_ROOT / "dwg_review" / "config.py").is_file()
|
||||
|
||||
|
||||
def test_read_env_prefers_environ(monkeypatch):
|
||||
monkeypatch.setenv("CAD_TEST_ENV_PROBE", "hello")
|
||||
assert config._read_env("CAD_TEST_ENV_PROBE") == "hello"
|
||||
|
||||
|
||||
def test_read_env_missing_returns_empty(monkeypatch):
|
||||
monkeypatch.delenv("CAD_TEST_DEFINITELY_MISSING_XYZ", raising=False)
|
||||
assert config._read_env("CAD_TEST_DEFINITELY_MISSING_XYZ") == ""
|
||||
|
||||
|
||||
def test_has_api_key_reflects_module_value(monkeypatch):
|
||||
monkeypatch.setattr(config, "API_KEY", "")
|
||||
assert config.has_api_key() is False
|
||||
monkeypatch.setattr(config, "API_KEY", "sk-x")
|
||||
assert config.has_api_key() is True
|
||||
|
||||
|
||||
def test_has_api_key_ignores_whitespace(monkeypatch):
|
||||
monkeypatch.setattr(config, "API_KEY", " ")
|
||||
assert config.has_api_key() is False
|
||||
|
||||
|
||||
def test_constants_have_expected_types():
|
||||
assert isinstance(config.HOST, str)
|
||||
assert isinstance(config.PORT, int)
|
||||
assert isinstance(config.CALL_TIMEOUT, float)
|
||||
assert isinstance(config.CALL_RETRIES, int)
|
||||
assert isinstance(config.MAX_FRAME_BYTES, int)
|
||||
assert isinstance(config.IMAGE_MAX_EDGE, int)
|
||||
assert isinstance(config.IMAGE_FORMAT, str)
|
||||
assert isinstance(config.CHECKLIST_PATH, type(config.PROJECT_ROOT))
|
||||
|
||||
|
||||
def test_checklist_path_resolves_relative_to_root():
|
||||
assert config.CHECKLIST_PATH.name == "review_checklist.json"
|
||||
assert config.CHECKLIST_PATH.parent == config.PROJECT_ROOT
|
||||
|
||||
|
||||
def test_port_default_matches_baseline():
|
||||
# 基座固定监听 127.0.0.1:8080;若此值被改动,请同步更新基座与文档。
|
||||
assert config.HOST == "127.0.0.1"
|
||||
assert config.PORT == 8080
|
||||
|
||||
|
||||
def test_os_name_guard_in_read_env(monkeypatch):
|
||||
# 非 Windows 平台不应触碰注册表
|
||||
monkeypatch.setattr(os, "name", "posix")
|
||||
monkeypatch.delenv("CAD_TEST_ENV_PROBE2", raising=False)
|
||||
assert config._read_env("CAD_TEST_ENV_PROBE2") == ""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""`dwg_review.phases.evidence` 单元测试。"""
|
||||
|
||||
from dwg_review.phases.evidence import bbox_to_window, collect_frame
|
||||
from tests.fakes import FakeMcpClient, frame, make_ctx, ok_image, ok_text
|
||||
|
||||
|
||||
def test_bbox_to_window_mapping():
|
||||
assert bbox_to_window([1, 2, 3, 4]) == {
|
||||
"min_x": 1.0,
|
||||
"min_y": 2.0,
|
||||
"max_x": 3.0,
|
||||
"max_y": 4.0,
|
||||
}
|
||||
|
||||
|
||||
def test_collect_frame_returns_none_for_missing_bbox():
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
assert collect_frame(ctx, 1, {}) is None
|
||||
assert collect_frame(ctx, 1, {"bbox": None}) is None
|
||||
|
||||
|
||||
def test_collect_frame_returns_none_for_bad_bbox_length():
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
assert collect_frame(ctx, 1, {"bbox": [0, 0, 10]}) is None
|
||||
|
||||
|
||||
def build_evidence_client(image=ok_image("QUJD")):
|
||||
return FakeMcpClient(
|
||||
responses={
|
||||
"query_entities": ok_text({"total": 5, "items": [{"handle": "A"}]}),
|
||||
"get_text_content": ok_text({"total": 1, "items": [{"text": "你好"}]}),
|
||||
"get_dimensions": ok_text({"total": 2, "items": []}),
|
||||
"get_block_references": ok_text({"total": 0, "items": []}),
|
||||
"get_polylines_info": ok_text({"total": 3, "items": []}),
|
||||
"get_viewport_screenshot": image,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_collect_frame_assembles_evidence():
|
||||
ctx = make_ctx(client=build_evidence_client())
|
||||
ev = collect_frame(ctx, 2, frame([0, 0, 100, 50]))
|
||||
assert ev["index"] == 2
|
||||
assert ev["window"] == {"min_x": 0.0, "min_y": 0.0, "max_x": 100.0, "max_y": 50.0}
|
||||
assert ev["entities"]["total"] == 5
|
||||
assert ev["texts"]["total"] == 1
|
||||
assert ev["dimensions"]["total"] == 2
|
||||
assert ev["blocks"]["total"] == 0
|
||||
assert ev["polylines"]["total"] == 3
|
||||
assert ev["image_b64"] == "QUJD"
|
||||
assert ev["image_mime"] == "image/png"
|
||||
|
||||
|
||||
def test_collect_frame_calls_expected_tools():
|
||||
ctx = make_ctx(client=build_evidence_client(), frame_limit=99)
|
||||
collect_frame(ctx, 1, frame([1, 2, 3, 4]))
|
||||
client = ctx.client
|
||||
|
||||
win = {"min_x": 1.0, "min_y": 2.0, "max_x": 3.0, "max_y": 4.0}
|
||||
assert client.arguments_of("zoom_to_window") == dict(win, margin=0.02)
|
||||
for name in (
|
||||
"query_entities",
|
||||
"get_text_content",
|
||||
"get_dimensions",
|
||||
"get_block_references",
|
||||
"get_polylines_info",
|
||||
):
|
||||
assert client.arguments_of(name) == {"window": win, "limit": 99}
|
||||
assert client.arguments_of("get_viewport_screenshot") == {}
|
||||
|
||||
|
||||
def test_collect_frame_without_image():
|
||||
ctx = make_ctx(client=build_evidence_client(image=ok_text({})))
|
||||
ev = collect_frame(ctx, 1, frame([0, 0, 10, 10]))
|
||||
assert ev["image_b64"] is None
|
||||
assert ev["image_mime"] is None
|
||||
|
||||
|
||||
def test_collect_frame_tolerates_empty_structured_data():
|
||||
ctx = make_ctx(client=FakeMcpClient()) # 全部默认 ok_text({})
|
||||
ev = collect_frame(ctx, 1, frame([0, 0, 10, 10]))
|
||||
assert ev["entities"] == {}
|
||||
assert ev["image_b64"] is None
|
||||
@@ -0,0 +1,188 @@
|
||||
"""`dwg_review.llm_client` 单元测试(绝不真实调用 LLM)。"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from dwg_review import config
|
||||
from dwg_review import llm_client as llm_module
|
||||
from dwg_review.llm_client import LlmClient, LlmError, downsample_image_b64
|
||||
from tests.fakes import llm_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 假 OpenAI SDK
|
||||
# ---------------------------------------------------------------------------
|
||||
class _Completions:
|
||||
def __init__(self, outer):
|
||||
self.outer = outer
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.outer.kwargs.append(kwargs)
|
||||
if self.outer.raises:
|
||||
raise RuntimeError("upstream boom")
|
||||
return self.outer.result
|
||||
|
||||
|
||||
class _Chat:
|
||||
def __init__(self, outer):
|
||||
self.completions = _Completions(outer)
|
||||
|
||||
|
||||
class _FakeOpenAI:
|
||||
def __init__(self, **kwargs):
|
||||
self.init_kwargs = kwargs
|
||||
self.kwargs = []
|
||||
self.raises = False
|
||||
self.result = None
|
||||
self.chat = _Chat(self)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_openai(monkeypatch):
|
||||
monkeypatch.setattr(llm_module, "OpenAI", _FakeOpenAI)
|
||||
monkeypatch.setattr(llm_module.time, "sleep", lambda *_: None)
|
||||
return _FakeOpenAI
|
||||
|
||||
|
||||
def build_client(fake_openai, **kw):
|
||||
return LlmClient(api_key="sk-test", **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 构造
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_init_requires_api_key(monkeypatch):
|
||||
monkeypatch.setattr(config, "API_KEY", "")
|
||||
with pytest.raises(LlmError):
|
||||
LlmClient()
|
||||
|
||||
|
||||
def test_init_passes_key_and_base_url(fake_openai):
|
||||
client = LlmClient(api_key="sk-abc", base_url="https://example.test")
|
||||
assert client._client.init_kwargs["api_key"] == "sk-abc"
|
||||
assert client._client.init_kwargs["base_url"] == "https://example.test"
|
||||
|
||||
|
||||
def test_init_uses_config_defaults(fake_openai, monkeypatch):
|
||||
monkeypatch.setattr(config, "MODEL_NAME", "test-model")
|
||||
monkeypatch.setattr(config, "BASE_URL", "https://cfg.test")
|
||||
client = LlmClient(api_key="sk-abc")
|
||||
assert client.model == "test-model"
|
||||
assert client._client.init_kwargs["base_url"] == "https://cfg.test"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat / chat_text
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_chat_returns_raw_response(fake_openai):
|
||||
client = build_client(fake_openai)
|
||||
client._client.result = llm_text("hi")
|
||||
resp = client.chat([{"role": "user", "content": "x"}])
|
||||
assert resp.choices[0].message.content == "hi"
|
||||
|
||||
|
||||
def test_chat_includes_tools_only_when_present(fake_openai):
|
||||
client = build_client(fake_openai)
|
||||
client._client.result = llm_text("x")
|
||||
client.chat([{"role": "user", "content": "x"}])
|
||||
assert "tools" not in client._client.kwargs[0]
|
||||
|
||||
client.chat([{"role": "user", "content": "x"}], tools=[{"type": "function"}])
|
||||
assert client._client.kwargs[1]["tools"] == [{"type": "function"}]
|
||||
|
||||
|
||||
def test_chat_wraps_exception_into_llmerror(fake_openai):
|
||||
client = build_client(fake_openai)
|
||||
client._client.raises = True
|
||||
with pytest.raises(LlmError):
|
||||
client.chat([{"role": "user", "content": "x"}], retries=0)
|
||||
|
||||
|
||||
def test_chat_retries_then_succeeds(fake_openai):
|
||||
client = build_client(fake_openai)
|
||||
|
||||
class Flaky(_Completions):
|
||||
def __init__(self, outer):
|
||||
super().__init__(outer)
|
||||
self.n = 0
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.n += 1
|
||||
if self.n < 3:
|
||||
raise RuntimeError("transient")
|
||||
return llm_text("ok")
|
||||
|
||||
client._client.chat.completions = Flaky(client._client)
|
||||
resp = client.chat([{"role": "user", "content": "x"}], retries=2)
|
||||
assert resp.choices[0].message.content == "ok"
|
||||
assert client._client.chat.completions.n == 3
|
||||
|
||||
|
||||
def test_chat_text_returns_assistant_text(fake_openai):
|
||||
client = build_client(fake_openai)
|
||||
client._client.result = llm_text("报告正文")
|
||||
assert client.chat_text("sys", "user") == "报告正文"
|
||||
|
||||
|
||||
def test_image_content_format():
|
||||
block = LlmClient.image_content("QUJD", "image/jpeg")
|
||||
assert block == {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,QUJD"},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 截图下采样
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_downsample_passthrough_without_pil(monkeypatch):
|
||||
monkeypatch.setattr(llm_module, "HAS_PIL", False)
|
||||
assert downsample_image_b64("QUJD", "image/png", max_edge=10) == ("QUJD", "image/png")
|
||||
|
||||
|
||||
def test_downsample_passthrough_when_max_edge_zero():
|
||||
assert downsample_image_b64("QUJD", "image/png", max_edge=0) == ("QUJD", "image/png")
|
||||
|
||||
|
||||
def test_downsample_passthrough_when_no_resize_needed():
|
||||
pytest.importorskip("PIL")
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (100, 80), "white").save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
out, mime = downsample_image_b64(b64, "image/png", max_edge=1280)
|
||||
assert out == b64 and mime == "image/png"
|
||||
|
||||
|
||||
def test_downsample_resizes_large_image():
|
||||
pytest.importorskip("PIL")
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (2000, 1000), "white").save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
out, mime = downsample_image_b64(b64, "image/png", max_edge=500)
|
||||
assert mime == "image/png"
|
||||
img = Image.open(io.BytesIO(base64.b64decode(out)))
|
||||
assert max(img.size) <= 500
|
||||
|
||||
|
||||
def test_downsample_converts_to_jpeg():
|
||||
pytest.importorskip("PIL")
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (2000, 1000), "white").save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
out, mime = downsample_image_b64(b64, "image/png", max_edge=400, fmt="jpeg")
|
||||
assert mime == "image/jpeg"
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out))).format == "JPEG"
|
||||
|
||||
|
||||
def test_downsample_bad_base64_returns_original():
|
||||
pytest.importorskip("PIL")
|
||||
bad = base64.b64encode(b"this is not an image").decode("ascii")
|
||||
assert downsample_image_b64(bad, "image/png", max_edge=10) == (bad, "image/png")
|
||||
@@ -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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""`dwg_review.phases.planning` 单元测试。"""
|
||||
|
||||
from dwg_review.phases.planning import _sort_frames, plan
|
||||
from tests.fakes import FakeMcpClient, frame, make_ctx, ok_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阅读顺序排序
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_sort_frames_top_to_bottom_then_left_to_right():
|
||||
# bbox = [min_x, min_y, max_x, max_y]
|
||||
top_left = frame([0, 100, 10, 200])
|
||||
top_right = frame([50, 100, 60, 200])
|
||||
bottom_left = frame([0, 0, 10, 50])
|
||||
bottom_right = frame([50, 0, 60, 50])
|
||||
|
||||
ordered = _sort_frames([bottom_right, top_left, bottom_left, top_right])
|
||||
assert ordered == [top_left, top_right, bottom_left, bottom_right]
|
||||
|
||||
|
||||
def test_sort_frames_same_row_by_x():
|
||||
a = frame([90, 0, 100, 10])
|
||||
b = frame([10, 0, 20, 10])
|
||||
assert _sort_frames([a, b]) == [b, a]
|
||||
|
||||
|
||||
def test_sort_frames_handles_missing_bbox():
|
||||
with_bbox = frame([0, 0, 10, 10])
|
||||
without = {"kind": "x"} # 缺 bbox -> 视作全 0
|
||||
ordered = _sort_frames([without, with_bbox])
|
||||
assert with_bbox in ordered and without in ordered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan()
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_plan_client(frames):
|
||||
return FakeMcpClient(
|
||||
responses={
|
||||
"get_drawing_info": ok_text({"units": "mm"}),
|
||||
"get_layers": ok_text({"layers": [{"name": "0"}]}),
|
||||
"get_layouts": ok_text({"layouts": [{"name": "Model"}]}),
|
||||
"detect_drawing_frames": ok_text({"items": frames}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_plan_returns_all_sections():
|
||||
frames = [frame([0, 0, 10, 10]), frame([0, 20, 10, 30])]
|
||||
ctx = make_ctx(client=build_plan_client(frames))
|
||||
result = plan(ctx)
|
||||
assert result["drawing_info"] == {"units": "mm"}
|
||||
assert result["layers"] == {"layers": [{"name": "0"}]}
|
||||
assert result["layouts"] == {"layouts": [{"name": "Model"}]}
|
||||
assert len(result["frames"]) == 2
|
||||
|
||||
|
||||
def test_plan_sorts_frames():
|
||||
lower = frame([0, 0, 10, 10])
|
||||
upper = frame([0, 50, 10, 60])
|
||||
ctx = make_ctx(client=build_plan_client([lower, upper]))
|
||||
assert plan(ctx)["frames"] == [upper, lower]
|
||||
|
||||
|
||||
def test_plan_respects_max_frames():
|
||||
frames = [frame([0, i * 10, 10, i * 10 + 5]) for i in range(5)]
|
||||
ctx = make_ctx(client=build_plan_client(frames), max_frames=2)
|
||||
assert len(plan(ctx)["frames"]) == 2
|
||||
|
||||
|
||||
def test_plan_calls_with_expected_arguments():
|
||||
ctx = make_ctx(client=build_plan_client([]))
|
||||
plan(ctx)
|
||||
client = ctx.client
|
||||
assert client.arguments_of("get_layers") == {"count_entities": True}
|
||||
assert client.arguments_of("detect_drawing_frames") == {"min_confidence": 0.3}
|
||||
|
||||
|
||||
def test_plan_tolerates_non_dict_frames_data():
|
||||
client = FakeMcpClient(
|
||||
responses={
|
||||
"get_drawing_info": ok_text({}),
|
||||
"get_layers": ok_text({}),
|
||||
"get_layouts": ok_text({}),
|
||||
"detect_drawing_frames": ok_text("oops"), # 非 dict
|
||||
}
|
||||
)
|
||||
assert plan(make_ctx(client=client))["frames"] == []
|
||||
|
||||
|
||||
def test_plan_tolerates_missing_items_key():
|
||||
client = FakeMcpClient(responses={"detect_drawing_frames": ok_text({"other": 1})})
|
||||
assert plan(make_ctx(client=client))["frames"] == []
|
||||
@@ -0,0 +1,118 @@
|
||||
"""`dwg_review.phases.report` 单元测试。"""
|
||||
|
||||
import json
|
||||
|
||||
from dwg_review.phases.report import extract_issues, save_report, summarize
|
||||
from tests.fakes import FakeLlmClient, make_ctx
|
||||
|
||||
|
||||
def findings_fixture():
|
||||
return [
|
||||
{"frame": 1, "sheet": "A1", "finding": "图框1:文字重叠"},
|
||||
{"frame": 2, "sheet": "A3", "finding": "图框2:未发现明显问题"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# summarize
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_summarize_empty_returns_message_without_llm():
|
||||
llm = FakeLlmClient(text="不应被调用")
|
||||
ctx = make_ctx(llm=llm)
|
||||
out = summarize(ctx, [], log=lambda *a: None)
|
||||
assert "未识别到可审查的图框" in out
|
||||
assert llm.calls == []
|
||||
|
||||
|
||||
def test_summarize_uses_chat_text():
|
||||
llm = FakeLlmClient(text="## 总报告")
|
||||
ctx = make_ctx(llm=llm)
|
||||
assert summarize(ctx, findings_fixture(), log=lambda *a: None) == "## 总报告"
|
||||
assert len(llm.calls) == 1
|
||||
assert "图框1" in llm.calls[0]["user"]
|
||||
|
||||
|
||||
def test_summarize_falls_back_to_raw_input_on_error():
|
||||
class Boom(FakeLlmClient):
|
||||
def chat_text(self, system, user, **kwargs):
|
||||
raise RuntimeError("汇总炸了")
|
||||
|
||||
logs = []
|
||||
ctx = make_ctx(llm=Boom())
|
||||
out = summarize(ctx, findings_fixture(), log=logs.append)
|
||||
assert "图框1" in out and "图框2" in out
|
||||
assert any("汇总失败" in line for line in logs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_issues
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_extract_issues_empty_returns_empty_list():
|
||||
llm = FakeLlmClient(text_script=["[]"])
|
||||
ctx = make_ctx(llm=llm)
|
||||
assert extract_issues(ctx, [], log=lambda *a: None) == []
|
||||
assert llm.calls == []
|
||||
|
||||
|
||||
def test_extract_issues_parses_json_array():
|
||||
payload = '[{"frame": 1, "handle": "A", "severity": "high", "message": "x"}]'
|
||||
llm = FakeLlmClient(text_script=[payload])
|
||||
ctx = make_ctx(llm=llm)
|
||||
issues = extract_issues(ctx, findings_fixture(), log=lambda *a: None)
|
||||
assert issues == [{"frame": 1, "handle": "A", "severity": "high", "message": "x"}]
|
||||
|
||||
|
||||
def test_extract_issues_strips_code_fence():
|
||||
fenced = '```json\n[{"frame": 1, "message": "y"}]\n```'
|
||||
llm = FakeLlmClient(text_script=[fenced])
|
||||
ctx = make_ctx(llm=llm)
|
||||
assert extract_issues(ctx, findings_fixture(), log=lambda *a: None) == [
|
||||
{"frame": 1, "message": "y"}
|
||||
]
|
||||
|
||||
|
||||
def test_extract_issues_returns_empty_on_invalid_json():
|
||||
logs = []
|
||||
llm = FakeLlmClient(text_script=["这不是 JSON"])
|
||||
ctx = make_ctx(llm=llm)
|
||||
assert extract_issues(ctx, findings_fixture(), log=logs.append) == []
|
||||
assert any("结构化抽取失败" in line for line in logs)
|
||||
|
||||
|
||||
def test_extract_issues_returns_empty_when_not_a_list():
|
||||
llm = FakeLlmClient(text_script=['{"frame": 1}'])
|
||||
ctx = make_ctx(llm=llm)
|
||||
assert extract_issues(ctx, findings_fixture(), log=lambda *a: None) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save_report
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_save_report_writes_md_and_json(tmp_reports):
|
||||
findings = findings_fixture()
|
||||
plan = {"frames": [{"bbox": [0, 0, 10, 10]}]}
|
||||
issues = [{"frame": 1, "handle": "A", "message": "x"}]
|
||||
|
||||
md_path, json_path = save_report("# 报告", findings, plan, issues, log=lambda *a: None)
|
||||
|
||||
assert md_path.exists() and json_path.exists()
|
||||
assert md_path.read_text(encoding="utf-8") == "# 报告"
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
assert data["findings"] == findings
|
||||
assert data["issues"] == issues
|
||||
assert data["frames"] == plan["frames"]
|
||||
assert "generated_at" in data
|
||||
|
||||
|
||||
def test_save_report_issues_default_to_empty(tmp_reports):
|
||||
_, json_path = save_report("md", findings_fixture(), {"frames": []}, log=lambda *a: None)
|
||||
assert json.loads(json_path.read_text(encoding="utf-8"))["issues"] == []
|
||||
|
||||
|
||||
def test_save_report_creates_missing_directory(tmp_path, monkeypatch):
|
||||
from dwg_review import config
|
||||
|
||||
target = tmp_path / "deep" / "nested"
|
||||
monkeypatch.setattr(config, "REPORT_DIR", target)
|
||||
save_report("md", [], {"frames": []}, log=lambda *a: None)
|
||||
assert target.is_dir()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""`dwg_review.phases.review` 单元测试。"""
|
||||
|
||||
import json
|
||||
|
||||
from dwg_review.cad_tools import READ_ONLY_TOOLS
|
||||
from dwg_review.mcp_client import McpToolError
|
||||
from dwg_review.phases.review import (
|
||||
_run_readonly_tool,
|
||||
evidence_payload,
|
||||
review_frame,
|
||||
)
|
||||
from tests.fakes import (
|
||||
FakeLlmClient,
|
||||
FakeMcpClient,
|
||||
llm_calls,
|
||||
llm_text,
|
||||
make_ctx,
|
||||
make_evidence,
|
||||
ok_image,
|
||||
ok_mixed,
|
||||
ok_text,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# evidence_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_evidence_payload_strips_image():
|
||||
ev = make_evidence(image_b64="QUJD", image_mime="image/png")
|
||||
payload = evidence_payload(ev)
|
||||
assert "image_b64" not in payload
|
||||
assert "image_mime" not in payload
|
||||
assert payload["frame_index"] == 1
|
||||
|
||||
|
||||
def test_evidence_payload_keeps_only_frame_metadata():
|
||||
ev = make_evidence()
|
||||
ev["frame"]["secret"] = "should-not-leak"
|
||||
payload = evidence_payload(ev)
|
||||
assert "secret" not in payload["frame"]
|
||||
assert set(payload["frame"]) == {"sheet_size", "kind", "layer", "bbox", "confidence"}
|
||||
|
||||
|
||||
def test_evidence_payload_carries_structured_data():
|
||||
ev = make_evidence(entities={"total": 9, "items": []}, texts={"total": 2})
|
||||
payload = evidence_payload(ev)
|
||||
assert payload["entities"] == {"total": 9, "items": []}
|
||||
assert payload["texts"] == {"total": 2}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_readonly_tool
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_readonly_rejects_non_whitelisted_tool():
|
||||
client = FakeMcpClient()
|
||||
ctx = make_ctx(client=client)
|
||||
content, budget = _run_readonly_tool(ctx, "get_some_unknown_tool", {}, [3])
|
||||
assert "不在只读取证白名单内" in content[0]["text"]
|
||||
assert client.calls == []
|
||||
assert budget == [3]
|
||||
|
||||
|
||||
def test_readonly_rejects_write_tool():
|
||||
client = FakeMcpClient()
|
||||
ctx = make_ctx(client=client)
|
||||
content, _ = _run_readonly_tool(ctx, "add_leader_annotation", {}, [3])
|
||||
assert "不在只读取证白名单内" in content[0]["text"]
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_readonly_whitelist_covers_expected_tools():
|
||||
assert {"query_entities", "get_entity_detail", "zoom_to_window"} <= READ_ONLY_TOOLS
|
||||
|
||||
|
||||
def test_readonly_handles_tool_error():
|
||||
client = FakeMcpClient(responses={"query_entities": McpToolError("炸了")})
|
||||
ctx = make_ctx(client=client)
|
||||
content, _ = _run_readonly_tool(ctx, "query_entities", {"window": {}}, [3])
|
||||
assert "执行失败" in content[0]["text"]
|
||||
|
||||
|
||||
def test_readonly_text_and_image_content():
|
||||
client = FakeMcpClient(
|
||||
responses={"query_entities": ok_mixed(["hi"], [("QUJD", "image/png")])}
|
||||
)
|
||||
ctx = make_ctx(client=client)
|
||||
content, budget = _run_readonly_tool(ctx, "query_entities", {}, [3])
|
||||
assert content[0] == {"type": "text", "text": "hi"}
|
||||
assert content[1]["type"] == "image_url"
|
||||
assert budget == [2]
|
||||
|
||||
|
||||
def test_readonly_image_budget_exhausted():
|
||||
client = FakeMcpClient(
|
||||
responses={"query_entities": ok_mixed(["t"], [("QUJD", "image/png")])}
|
||||
)
|
||||
ctx = make_ctx(client=client)
|
||||
content, budget = _run_readonly_tool(ctx, "query_entities", {}, [0])
|
||||
assert budget == [0]
|
||||
assert any("已省略" in c.get("text", "") for c in content)
|
||||
|
||||
|
||||
def test_readonly_empty_response_marker():
|
||||
empty = {"jsonrpc": "2.0", "id": 1, "result": {"content": []}}
|
||||
client = FakeMcpClient(responses={"query_entities": empty})
|
||||
ctx = make_ctx(client=client)
|
||||
content, _ = _run_readonly_tool(ctx, "query_entities", {}, [3])
|
||||
assert content == [{"type": "text", "text": "(无返回内容)"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review_frame
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_review_frame_returns_text_when_no_tools():
|
||||
llm = FakeLlmClient(script=[llm_text("结论A")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
out = review_frame(ctx, make_evidence(), "系统提示", log=lambda *a: None)
|
||||
assert out == "结论A"
|
||||
assert len(llm.calls) == 1
|
||||
assert llm.calls[0]["tools"] is not None # 传了只读工具 schema
|
||||
|
||||
|
||||
def test_review_frame_includes_image_block_when_present():
|
||||
llm = FakeLlmClient(script=[llm_text("ok")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
ev = make_evidence(image_b64="QUJD", image_mime="image/png")
|
||||
review_frame(ctx, ev, "sys", log=lambda *a: None)
|
||||
user_content = llm.calls[0]["messages"][1]["content"]
|
||||
assert any(b.get("type") == "image_url" for b in user_content)
|
||||
|
||||
|
||||
def test_review_frame_omits_image_block_when_absent():
|
||||
llm = FakeLlmClient(script=[llm_text("ok")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
user_content = llm.calls[0]["messages"][1]["content"]
|
||||
assert all(b.get("type") == "text" for b in user_content)
|
||||
|
||||
|
||||
def test_review_frame_runs_tool_then_concludes():
|
||||
llm = FakeLlmClient(
|
||||
script=[
|
||||
llm_calls([("query_entities", {"window": {}})]),
|
||||
llm_text("结论B"),
|
||||
]
|
||||
)
|
||||
client = FakeMcpClient(responses={"query_entities": ok_text({"total": 1})})
|
||||
ctx = make_ctx(client=client, llm=llm)
|
||||
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
assert out == "结论B"
|
||||
assert len(llm.calls) == 2
|
||||
assert client.called_names() == ["query_entities"]
|
||||
|
||||
|
||||
def test_review_frame_feeds_observation_back_as_tool_message():
|
||||
llm = FakeLlmClient(script=[llm_calls([("query_entities", {})]), llm_text("done")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
tool_msgs = [m for m in llm.calls[1]["messages"] if isinstance(m, dict) and m.get("role") == "tool"]
|
||||
assert tool_msgs and tool_msgs[0]["tool_call_id"] == "1"
|
||||
|
||||
|
||||
def test_review_frame_invalid_tool_arguments_become_empty():
|
||||
llm = FakeLlmClient(
|
||||
script=[llm_calls([("query_entities", "not-json")]), llm_text("done")]
|
||||
)
|
||||
client = FakeMcpClient()
|
||||
ctx = make_ctx(client=client, llm=llm)
|
||||
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
assert client.arguments_of("query_entities") == {}
|
||||
|
||||
|
||||
def test_review_frame_force_closes_at_iteration_limit():
|
||||
llm = FakeLlmClient(
|
||||
script=[
|
||||
llm_calls([("query_entities", {})]),
|
||||
llm_text("收口结论"),
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm, max_iters=1)
|
||||
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
assert out == "收口结论"
|
||||
last_msg = llm.calls[-1]["messages"][-1]
|
||||
assert last_msg["role"] == "user"
|
||||
assert "上限" in last_msg["content"]
|
||||
|
||||
|
||||
def test_review_frame_with_zero_iters_forces_immediate_close():
|
||||
llm = FakeLlmClient(script=[llm_text("直接结论")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm, max_iters=0)
|
||||
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
assert out == "直接结论"
|
||||
assert len(llm.calls) == 1
|
||||
|
||||
|
||||
def test_review_frame_logs_tool_usage():
|
||||
logs = []
|
||||
llm = FakeLlmClient(script=[llm_calls([("get_layers", {})]), llm_text("x")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
review_frame(ctx, make_evidence(), "sys", log=logs.append)
|
||||
assert any("取证" in line and "get_layers" in line for line in logs)
|
||||
|
||||
|
||||
def test_review_frame_payload_is_json_serializable():
|
||||
llm = FakeLlmClient(script=[llm_text("ok")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
text = llm.calls[0]["messages"][1]["content"][0]["text"]
|
||||
json.loads(text.split(":\n", 1)[1]) # 不抛异常即通过
|
||||
Reference in new issue
Block a user