"""`dwg_review.inspect` 单元测试(agent 侧虚拟工具 `inspect_region`)。""" import base64 import io import pytest from dwg_review import config, inspect from dwg_review.inspect import ( InspectState, _point_box, inspect_region, plan_tiles, readable_px, resolve_target, ) from dwg_review.phases.review import review_frame from tests.fakes import ( FakeLlmClient, FakeMcpClient, frame, llm_calls, llm_text, make_ctx, make_evidence, ok_image, ) @pytest.fixture def inspect_env(monkeypatch): """钉死 inspect 相关配置,使用例不受外部环境变量影响。""" for name, value in { "TEXT_HEIGHT_MM": 2.0, "TEXT_READABLE_PX": 8, "LINE_READABLE_PX": 6, "INSPECT_MAX_TILES": 4, "INSPECT_ENABLED": True, "TILE_OVERLAP": 0.0, "IMAGE_MAX_EDGE": 1280, "RENDER_WAIT": 0.0, }.items(): monkeypatch.setattr(config, name, value) return config def _png_b64(w=2000, h=1414): from PIL import Image buf = io.BytesIO() Image.new("RGB", (w, h), "black").save(buf, format="PNG") return base64.b64encode(buf.getvalue()).decode("ascii") def _evidence(images=None, entities=None, sheet_scale=100.0, sheet_size="A0"): """图框 (0,0)-(120000,40000)、1:100、A0 的 evidence(含 overall 截图)。""" ev = make_evidence() ev["frame"] = frame([0, 0, 120000, 40000], sheet_size=sheet_size, sheet_scale=sheet_scale) ev["window"] = {"min_x": 0.0, "min_y": 0.0, "max_x": 120000.0, "max_y": 40000.0} ev["entities"] = entities or { "total": 1, "items": [{"handle": "A1", "type": "LINE", "bbox": [0, 0, 1000, 500]}], } if images is None: images = [ { "b64": _png_b64(), "mime": "image/png", "label": "overall", "window": (0.0, 0.0, 120000.0, 40000.0), } ] ev["images"] = images return ev def _client(b64=None): return FakeMcpClient( responses={"get_viewport_screenshot": ok_image(b64 or _png_b64())} ) # --------------------------------------------------------------------------- # InspectState # --------------------------------------------------------------------------- def test_inspect_state_registers_evidence_labels_with_window(): ev = _evidence(images=[ {"b64": "A", "mime": "image/png", "label": "overall", "window": (0, 0, 10, 10)}, {"b64": "B", "mime": "image/png", "label": "tile-1", "window": [0, 0, 5, 5]}, {"b64": "C", "mime": "image/png", "label": "tile-2"}, # 无 window → 不登记 ]) state = InspectState(ev) assert set(state.labels) == {"overall", "tile-1"} assert state.labels["tile-1"] == (0.0, 0.0, 5.0, 5.0) def test_inspect_state_register_assigns_incrementing_labels(): state = InspectState(_evidence()) assert state.register((1, 2, 3, 4)) == "inspect-1" assert state.register((0, 0, 1, 1)) == "inspect-2" assert state.labels["inspect-1"] == (1, 2, 3, 4) # --------------------------------------------------------------------------- # _point_box / resolve_target # --------------------------------------------------------------------------- def test_point_box_flips_y_because_cy_is_image_down(): base = (0.0, 0.0, 100.0, 50.0) top = _point_box(base, 0.5, 0.0, 0.5) # 图像顶部 bottom = _point_box(base, 0.5, 1.0, 0.5) # 图像底部 assert (top[1] + top[3]) / 2 == pytest.approx(50.0) # → 世界 y 大的一侧 assert (bottom[1] + bottom[3]) / 2 == pytest.approx(0.0) def test_point_box_centers_and_clamps_span(): base = (0.0, 0.0, 100.0, 50.0) box = _point_box(base, 0.5, 0.5, 0.5) assert box == pytest.approx((25.0, 12.5, 75.0, 37.5)) assert _point_box(base, 0.5, 0.5, 5.0) == pytest.approx(base) # span 上限 1 tiny = _point_box(base, 0.5, 0.5, 0.0) assert (tiny[2] - tiny[0]) == pytest.approx(100.0 * 0.02) # span 下限 0.02 def test_resolve_target_by_handle_is_case_insensitive(): ev = _evidence() box, note = resolve_target(ev, {"handle": "a1"}, InspectState(ev)) assert box == (0.0, 0.0, 1000.0, 500.0) assert "a1" in note def test_resolve_target_handle_not_found(): ev = _evidence() box, note = resolve_target(ev, {"handle": "ZZ"}, InspectState(ev)) assert box is None assert "ZZ" in note and "window" in note def test_resolve_target_by_window(): ev = _evidence() box, note = resolve_target( ev, {"window": {"min_x": 1, "min_y": 2, "max_x": 3, "max_y": 4}}, InspectState(ev) ) assert box == (1.0, 2.0, 3.0, 4.0) assert note def test_resolve_target_bad_window(): ev = _evidence() box, note = resolve_target(ev, {"window": {"min_x": 1}}, InspectState(ev)) assert box is None assert "window 非法" in note def test_resolve_target_by_label_point(): ev = _evidence() state = InspectState(ev) box, note = resolve_target( ev, {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}, state ) # overall 覆盖 (0,0)-(120000,40000) → 中心 (60000, 20000),跨度为半幅 assert box == pytest.approx((30000.0, 10000.0, 90000.0, 30000.0)) assert "overall" in note def test_resolve_target_unknown_label_lists_known(): ev = _evidence() box, note = resolve_target(ev, {"label": "tile-9"}, InspectState(ev)) assert box is None assert "tile-9" in note and "overall" in note def test_resolve_target_rejects_bad_inputs(): ev = _evidence() state = InspectState(ev) assert resolve_target(ev, "nope", state)[0] is None assert resolve_target(ev, {}, state)[0] is None assert resolve_target(ev, {"label": "overall", "cx": "x"}, state)[0] is None # --------------------------------------------------------------------------- # plan_tiles # --------------------------------------------------------------------------- def test_readable_px_by_purpose(inspect_env): assert readable_px("text") == inspect_env.TEXT_READABLE_PX assert readable_px("line") == inspect_env.LINE_READABLE_PX def test_plan_tiles_single_tile_for_small_region(inspect_env): wins, note = plan_tiles( (0, 0, 1000, 500), 100.0, "text", (2000, 1414), max_tiles=4, overlap=0.0, margin=0.02, ) assert len(wins) == 1 assert "1×1" in note # 目标框被外扩 5% 留边 assert wins[0]["min_x"] < 0 and wins[0]["max_x"] > 1000 def test_plan_tiles_splits_wide_region(inspect_env): # 66000 世界单位宽(含 5% 留边)/ 单张 32000 → 3 张 wins, note = plan_tiles( (0, 0, 60000, 20000), 100.0, "text", (2000, 1414), max_tiles=9, overlap=0.0, margin=0.02, ) assert len(wins) == 3 assert "3 张" in note def test_plan_tiles_fatal_when_over_cap(inspect_env): wins, note = plan_tiles( (0, 0, 100000, 100000), 100.0, "text", (2000, 1414), max_tiles=4, overlap=0.0, margin=0.02, ) assert wins is None assert "超过单次上限 4" in note assert "span" in note # 给出缩小 target 的具体指引 def test_plan_tiles_falls_back_to_single_without_scale(inspect_env): wins, note = plan_tiles( (0, 0, 1000, 500), 0.0, "text", (2000, 1414), max_tiles=4, overlap=0.0, margin=0.02, ) assert len(wins) == 1 assert "退回单张" in note def test_plan_tiles_degenerate_box_is_fatal(inspect_env): wins, note = plan_tiles( (0, 0, 0, 0), 100.0, "text", (2000, 1414), max_tiles=4, overlap=0.0, margin=0.02, ) assert wins is None assert "退化" in note # --------------------------------------------------------------------------- # inspect_region(执行体) # --------------------------------------------------------------------------- def test_inspect_region_handle_success(inspect_env): pytest.importorskip("PIL") client = _client() ctx = make_ctx(client=client) ev = _evidence() state = InspectState(ev) content, budget = inspect_region(ctx, ev, {"target": {"handle": "A1"}}, [6], state) assert budget[0] == 5 # 只用掉 1 张 assert "实体 A1" in content[0]["text"] assert any(c.get("type") == "image_url" for c in content) assert "zoom_to_window" in client.called_names() assert "inspect-1" in state.labels # 登记的可见范围应**包含**目标框(ZOOM W 等比适配会露出更多) vis = state.labels["inspect-1"] assert vis[0] <= 0.0 and vis[2] >= 1000.0 def test_inspect_region_label_target_reports_label_and_range(inspect_env): pytest.importorskip("PIL") ctx = make_ctx(client=_client()) ev = _evidence() state = InspectState(ev) content, budget = inspect_region( ctx, ev, {"target": {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}}, [6], state ) assert budget[0] == 3 # 中心半幅需 3 张瓦片 assert "overall" in content[0]["text"] assert any("可见世界范围" in c.get("text", "") for c in content) def test_inspect_region_purpose_line_is_offered(inspect_env): pytest.importorskip("PIL") ctx = make_ctx(client=_client()) ev = _evidence() content, _ = inspect_region( ctx, ev, {"target": {"handle": "A1"}, "purpose": "line"}, [6], InspectState(ev) ) assert "line" in content[0]["text"] def test_inspect_region_bad_target_makes_no_mcp_calls(inspect_env): client = _client() ctx = make_ctx(client=client) ev = _evidence() content, budget = inspect_region( ctx, ev, {"target": {"label": "nope"}}, [6], InspectState(ev) ) assert budget[0] == 6 assert "未执行" in content[0]["text"] assert client.called_names() == [] def test_inspect_region_oversized_target_makes_no_mcp_calls(inspect_env): client = _client() ctx = make_ctx(client=client) ev = _evidence() content, budget = inspect_region( ctx, ev, {"target": {"window": {"min_x": 0, "min_y": 0, "max_x": 200000, "max_y": 200000}}}, [6], InspectState(ev), ) assert budget[0] == 6 assert "超过单次上限" in content[0]["text"] assert client.called_names() == [] def test_inspect_region_budget_exhausted_returns_no_images(inspect_env): pytest.importorskip("PIL") ctx = make_ctx(client=_client()) ev = _evidence() content, budget = inspect_region( ctx, ev, {"target": {"handle": "A1"}}, [0], InspectState(ev) ) assert budget[0] == 0 assert all(c.get("type") == "text" for c in content) assert any("预算已用完" in c.get("text", "") for c in content) # --------------------------------------------------------------------------- # 与审查子循环的集成 # --------------------------------------------------------------------------- def test_review_frame_can_call_inspect_region(inspect_env): pytest.importorskip("PIL") llm = FakeLlmClient(script=[ llm_calls([("inspect_region", {"target": {"handle": "A1"}})]), llm_text('{"summary": "结论", "issues": []}'), ]) client = _client() ctx = make_ctx(client=client, llm=llm) ev = _evidence() out = review_frame(ctx, ev, "sys", log=lambda *a: None) assert out["summary"] == "结论" names = client.called_names() assert "zoom_to_window" in names # inspect_region 内部真的缩放了 assert names.count("zoom_to_window") == 2 # 放大 1 次 + 结束复位 1 次 def test_review_frame_offers_inspect_region_to_llm(inspect_env): llm = FakeLlmClient(script=[llm_text("x")]) ctx = make_ctx(client=FakeMcpClient(), llm=llm) review_frame(ctx, make_evidence(), "sys", log=lambda *a: None) names = {t["function"]["name"] for t in llm.calls[0]["tools"]} assert "inspect_region" in names assert "zoom_to_window" not in names