"""`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