Files
cad-agent/tests/test_annotator.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

186 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`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_dedupes_repeated_handle():
"""同一 handle 出现两次只批注一次(created 与 highlighted 均去重)。"""
ctx = make_ctx(client=build_client())
created, highlighted = annotate(
ctx,
[
{"handle": "A", "severity": "high", "message": "第一次"},
{"handle": "A", "severity": "high", "message": "第二次"},
],
log=lambda *a: None,
)
assert created == ["NEW1"]
assert highlighted == ["A"]
assert len(ctx.client.called("add_leader_annotation")) == 1
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)
# ---------------------------------------------------------------------------
# 离线源(tools=None)
# ---------------------------------------------------------------------------
def test_annotate_offline_returns_empty_without_touching_cad():
logs = []
ctx = make_ctx(connect_cad=False)
created, highlighted = annotate(
ctx, [{"handle": "H1", "severity": "high", "message": "x"}], log=logs.append
)
assert created == [] and highlighted == []
assert any("离线" in line for line in logs)
def test_clear_offline_noop():
ctx = make_ctx(connect_cad=False)
clear(ctx, handles=["N1"], highlighted=["A"], log=lambda *a: None) # 不应抛异常