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

144 lines
5.1 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.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"] == []
# ---------------------------------------------------------------------------
# 文件源分发(PDF / 图片)
# ---------------------------------------------------------------------------
def test_plan_dispatches_file_source_to_sources(tmp_path, monkeypatch):
"""ctx.source 为文件源时走 `sources.plan_file`,**完全不碰 CAD 工具**。"""
from PIL import Image
from dwg_review import sources
path = tmp_path / "tiny.png"
Image.new("RGB", (60, 40), (255, 255, 255)).save(path)
ctx = make_ctx(connect_cad=False, source="image", source_path=str(path))
try:
result = plan(ctx)
finally:
sources.close_reader(ctx)
assert result["drawing_info"]["unit"] == "px"
assert len(result["frames"]) == 1
assert result["frames"][0]["source_type"] == "image"
# 离线:没有任何 MCP 调用(ctx.tools 为 None 本身就是证据)
assert ctx.tools is None
def test_plan_file_source_does_not_sort_frames(tmp_path, monkeypatch):
"""文件源**保持原始页序**(阅读顺序由文档自身决定,不能按 bbox 重排)。"""
from dwg_review import sources
ctx = make_ctx(connect_cad=False, source="pdf", source_path="unused.pdf", max_frames=3)
class _Reader:
page_count = 3
def frames(self):
# 故意让 bbox 顺序与页序相反(后页在上方)
return [
{"bbox": [0.0, 0.0, 10.0, 10.0], "page_index": 0, "source_type": "pdf"},
{"bbox": [0.0, 50.0, 10.0, 60.0], "page_index": 1, "source_type": "pdf"},
{"bbox": [0.0, 90.0, 10.0, 100.0], "page_index": 2, "source_type": "pdf"},
]
def close(self):
pass
ctx.source_reader = _Reader()
result = sources.plan_file(ctx)
assert [f["page_index"] for f in result["frames"]] == [0, 1, 2]