Files
cad-agent/tests/test_checks.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

323 lines
13 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.checks` 单元测试:确定性检查前置。"""
from dwg_review import checks
from dwg_review.checks import (
check_collisions,
check_duplicate_entities,
check_zero_length,
derive_from_evidence,
make_issue,
run_deterministic_checks,
)
from dwg_review.mcp_client import McpError, McpToolError
from tests.fakes import FakeMcpClient, make_ctx, make_evidence, ok_text
QUIET = lambda *a: None # noqa: E731
WINDOW = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0}
# ---------------------------------------------------------------------------
# make_issue
# ---------------------------------------------------------------------------
def test_make_issue_minimal():
issue = make_issue("几何", "medium", "零长度")
assert issue == {"category": "几何", "severity": "medium",
"message": "零长度", "source": "deterministic"}
def test_make_issue_with_handles():
issue = make_issue("几何", "low", "重复", handle="A", handles=["A", "B"])
assert issue["handle"] == "A"
assert issue["handles"] == ["A", "B"]
def test_make_issue_omits_empty_handle():
assert "handle" not in make_issue("文字", "low", "空文字", handle=None)
# ---------------------------------------------------------------------------
# check_zero_length
# ---------------------------------------------------------------------------
def test_zero_length_turns_items_into_issues():
client = FakeMcpClient(responses={"find_zero_length": ok_text({
"total": 2, "returned": 2, "offset": 0,
"items": [
{"handle": "A1", "type": "LINE", "layer": "0", "length": 0.0},
{"handle": "A2", "type": "ARC", "layer": "0", "length": 0.0001},
],
})})
ctx = make_ctx(client=client)
issues = check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=QUIET)
assert [i["handle"] for i in issues] == ["A1", "A2"]
assert all(i["category"] == "几何" and i["severity"] == "medium" for i in issues)
def test_zero_length_skips_items_without_handle():
client = FakeMcpClient(responses={"find_zero_length": ok_text(
{"items": [{"type": "LINE"}, {"handle": "B1"}]}
)})
ctx = make_ctx(client=client)
assert [i["handle"] for i in check_zero_length(ctx, WINDOW, tolerance=0.001,
limit=200, log=QUIET)] == ["B1"]
def test_zero_length_passes_window_and_params():
client = FakeMcpClient(responses={"find_zero_length": ok_text({"items": []})})
ctx = make_ctx(client=client)
check_zero_length(ctx, WINDOW, tolerance=0.5, limit=77, log=QUIET)
assert client.arguments_of("find_zero_length") == {
"window": WINDOW, "tolerance": 0.5, "limit": 77,
}
def test_zero_length_swallows_tool_error():
logs = []
client = FakeMcpClient(responses={"find_zero_length": McpToolError("炸了")})
ctx = make_ctx(client=client)
assert check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=logs.append) == []
assert any("find_zero_length 失败" in line for line in logs)
def test_zero_length_tolerates_non_dict_envelope():
client = FakeMcpClient(responses={"find_zero_length": ok_text("oops")})
ctx = make_ctx(client=client)
assert check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=QUIET) == []
# ---------------------------------------------------------------------------
# check_duplicate_entities
# ---------------------------------------------------------------------------
def test_duplicate_group_becomes_single_issue_with_all_handles():
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({
"count": 1,
"groups": [{"reason": "identical_geometry", "type": "LINE",
"handles": ["B1", "B2", "B3"]}],
})})
ctx = make_ctx(client=client)
issues = check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50, log=QUIET)
assert len(issues) == 1 # 一组 = 一个问题
assert issues[0]["handle"] == "B1"
assert issues[0]["handles"] == ["B1", "B2", "B3"]
assert issues[0]["severity"] == "low"
def test_duplicate_skips_empty_groups():
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({
"groups": [{"handles": []}, {"handles": None}, {"handles": ["C1", "C2"]}],
})})
ctx = make_ctx(client=client)
issues = check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50, log=QUIET)
assert [i["handle"] for i in issues] == ["C1"]
def test_duplicate_passes_group_limit():
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({"groups": []})})
ctx = make_ctx(client=client)
check_duplicate_entities(ctx, WINDOW, tolerance=0.2, group_limit=9, log=QUIET)
assert client.arguments_of("find_duplicate_entities") == {
"window": WINDOW, "tolerance": 0.2, "limit": 9,
}
def test_duplicate_swallows_tool_error():
logs = []
client = FakeMcpClient(responses={"find_duplicate_entities": McpError(-32603, "x")})
ctx = make_ctx(client=client)
assert check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50,
log=logs.append) == []
assert any("find_duplicate_entities 失败" in line for line in logs)
# ---------------------------------------------------------------------------
# check_collisions
# ---------------------------------------------------------------------------
def _collision_item(a="A1", b="B1", ratio=0.8, same_layer=True):
return {
"a": {"handle": a, "type": "LINE", "layer": "0", "bbox": {}},
"b": {"handle": b, "type": "LWPOLYLINE", "layer": "0", "bbox": {}},
"overlap": {"bbox": {}, "width": 1.0, "height": 1.0, "area": 1.0, "ratio": ratio},
"same_layer": same_layer,
"same_type": False,
}
def test_collisions_turn_items_into_issues():
client = FakeMcpClient(responses={"find_collisions": ok_text({
"total": 2, "returned": 2, "offset": 0, "entity_count": 10,
"items": [_collision_item("A1", "B1", 0.9), _collision_item("A2", "B2", 0.6)],
})})
ctx = make_ctx(client=client)
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
assert [i["handles"] for i in issues] == [["A1", "B1"], ["A2", "B2"]]
assert all(i["category"] == "干涉" and i["severity"] == "medium" for i in issues)
assert all(i["handle"] == i["handles"][0] for i in issues)
assert "0.90" in issues[0]["message"]
def test_collisions_skips_items_missing_handles():
client = FakeMcpClient(responses={"find_collisions": ok_text({
"items": [
{"a": {"handle": "A1"}, "b": {}},
{"a": {}, "b": {"handle": "B1"}},
_collision_item("A2", "B2"),
],
})})
ctx = make_ctx(client=client)
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
assert [i["handles"] for i in issues] == [["A2", "B2"]]
def test_collisions_tolerates_missing_ratio():
client = FakeMcpClient(responses={"find_collisions": ok_text({
"items": [{"a": {"handle": "A1"}, "b": {"handle": "B1"}, "overlap": {}}],
})})
ctx = make_ctx(client=client)
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
assert len(issues) == 1
assert "重叠比 ?" in issues[0]["message"]
def test_collisions_passes_params():
client = FakeMcpClient(responses={"find_collisions": ok_text({"items": []})})
ctx = make_ctx(client=client)
check_collisions(ctx, WINDOW, min_overlap=0.75, limit=33, log=QUIET)
assert client.arguments_of("find_collisions") == {
"window": WINDOW, "min_overlap": 0.75, "limit": 33,
}
def test_collisions_swallows_tool_error():
logs = []
client = FakeMcpClient(responses={"find_collisions": McpToolError("炸了")})
ctx = make_ctx(client=client)
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=logs.append) == []
assert any("find_collisions 失败" in line for line in logs)
def test_collisions_tolerates_non_dict_envelope():
client = FakeMcpClient(responses={"find_collisions": ok_text("oops")})
ctx = make_ctx(client=client)
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) == []
# ---------------------------------------------------------------------------
# derive_from_evidence
# ---------------------------------------------------------------------------
def test_derive_flags_empty_and_whitespace_texts():
ev = make_evidence(texts={"items": [
{"handle": "T1", "content": ""},
{"handle": "T2", "content": " "},
{"handle": "T3", "content": None},
{"handle": "T4", "content": "正常文字"},
]})
issues = derive_from_evidence(ev)
assert [i["handle"] for i in issues] == ["T1", "T2", "T3"]
assert all(i["category"] == "文字" for i in issues)
def test_derive_flags_unclosed_polyline():
ev = make_evidence(polylines={"items": [
{"handle": "P1", "vertex_count": 4, "closed": False},
{"handle": "P2", "vertex_count": 4, "closed": True},
{"handle": "P3", "vertex_count": 2, "closed": False},
{"handle": "P4", "closed": False},
]})
issues = derive_from_evidence(ev)
assert [i["handle"] for i in issues] == ["P1"]
def test_derive_can_disable_unclosed_check():
ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]})
assert derive_from_evidence(ev, check_unclosed=False) == []
def test_derive_tolerates_missing_sources():
assert derive_from_evidence({}) == []
assert derive_from_evidence(make_evidence()) == []
# ---------------------------------------------------------------------------
# run_deterministic_checks
# ---------------------------------------------------------------------------
def build_ctx():
client = FakeMcpClient(responses={
"find_zero_length": ok_text({"items": [{"handle": "Z1", "type": "LINE", "length": 0.0}]}),
"find_duplicate_entities": ok_text({"groups": [
{"type": "LINE", "handles": ["D1", "D2"]}]}),
"find_collisions": ok_text({"items": [_collision_item("C1", "C2", 0.9)]}),
})
return make_ctx(client=client)
def test_run_combines_derived_and_mcp():
ctx = build_ctx()
ev = make_evidence(
texts={"items": [{"handle": "T1", "content": ""}]},
polylines={"items": [{"handle": "P1", "vertex_count": 3, "closed": False}]},
)
issues = run_deterministic_checks(ctx, ev, log=QUIET)
by_handle = {i.get("handle"): i for i in issues}
assert set(by_handle) == {"T1", "P1", "Z1", "D1"}
assert by_handle["D1"]["handles"] == ["D1", "D2"]
def test_run_passes_window_to_mcp_tools():
ctx = build_ctx()
ev = make_evidence(bbox=(0.0, 0.0, 10.0, 10.0))
run_deterministic_checks(ctx, ev, log=QUIET)
assert ctx.client.arguments_of("find_zero_length")["window"] == ev["window"]
assert ctx.client.arguments_of("find_duplicate_entities")["window"] == ev["window"]
def test_run_without_window_skips_mcp_but_keeps_derived():
ctx = build_ctx()
ev = make_evidence()
ev["window"] = None
ev["texts"] = {"items": [{"handle": "T1", "content": ""}]}
issues = run_deterministic_checks(ctx, ev, log=QUIET)
assert [i["handle"] for i in issues] == ["T1"]
assert ctx.client.calls == []
def test_run_respects_unclosed_switch(monkeypatch):
monkeypatch.setattr(checks.config, "CHECK_UNCLOSED_POLYLINES", False)
ctx = build_ctx()
ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]})
assert [i["handle"] for i in run_deterministic_checks(ctx, ev, log=QUIET)] == ["Z1", "D1"]
def test_run_collisions_off_by_default(monkeypatch):
# 默认不跑干涉粗筛(AABB 相交噪声大),工具调用里不应出现 find_collisions
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", False)
ctx = build_ctx()
run_deterministic_checks(ctx, make_evidence(), log=QUIET)
assert "find_collisions" not in ctx.client.called_names()
def test_run_collisions_when_enabled(monkeypatch):
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", True)
monkeypatch.setattr(checks.config, "COLLISION_MIN_OVERLAP", 0.6)
ctx = build_ctx()
issues = run_deterministic_checks(ctx, make_evidence(), log=QUIET)
by_handle = {i.get("handle"): i for i in issues}
assert "C1" in by_handle
assert by_handle["C1"]["category"] == "干涉"
assert ctx.client.arguments_of("find_collisions")["min_overlap"] == 0.6
# ---------------------------------------------------------------------------
# 离线源(tools=None)
# ---------------------------------------------------------------------------
def test_run_offline_only_derives_from_evidence():
"""离线源没有 CAD 工具:只跑纯 Python 派生检查,不调任何 MCP 工具。"""
ctx = make_ctx(connect_cad=False)
ev = make_evidence(
texts={"total": 1, "items": [{"handle": "T1", "content": " "}]},
polylines={"total": 1, "items": [
{"handle": "P1", "vertex_count": 4, "closed": False}]},
)
issues = run_deterministic_checks(ctx, ev, log=QUIET)
cats = {i["category"] for i in issues}
assert "文字" in cats and "几何" in cats