"""`dwg_review.findings` 单元测试:结构化解析 + handle 交叉校验 + 装配。""" from dwg_review.findings import ( build_finding, collect_known_handles, extract_json_block, flatten_issues, normalize_issue, parse_conclusion, ) from tests.fakes import make_evidence QUIET = lambda *a: None # noqa: E731 # --------------------------------------------------------------------------- # extract_json_block # --------------------------------------------------------------------------- def test_extract_bare_object(): assert extract_json_block('{"a": 1}') == '{"a": 1}' def test_extract_bare_array(): assert extract_json_block('[{"a": 1}]') == '[{"a": 1}]' def test_extract_fenced_object(): assert extract_json_block('```json\n{"a": 1}\n```') == '{"a": 1}' def test_extract_single_line_fence(): # 旧 extract_issues 的已知瑕疵(单行围栏剥离失败)在此不再复现 assert extract_json_block('```json{"a": 1}```') == '{"a": 1}' def test_extract_from_surrounding_prose(): text = '好的,结论如下:\n{"summary": "ok", "issues": []}\n以上。' assert extract_json_block(text) == '{"summary": "ok", "issues": []}' def test_extract_handles_braces_inside_strings(): text = 'prefix {"message": "包含 } 和 { 的文字", "n": 1} suffix' assert extract_json_block(text) == '{"message": "包含 } 和 { 的文字", "n": 1}' def test_extract_returns_none_for_garbage(): assert extract_json_block("未发现明显问题") is None assert extract_json_block("") is None assert extract_json_block(None) is None assert extract_json_block("{ not json") is None # --------------------------------------------------------------------------- # parse_conclusion # --------------------------------------------------------------------------- def test_parse_object_with_issues(): text = '{"summary": "整体尚可", "issues": [{"category": "几何", "message": "x"}]}' out = parse_conclusion(text, log=QUIET) assert out["summary"] == "整体尚可" assert out["issues"] == [{"category": "几何", "message": "x"}] assert out["raw"] == text def test_parse_object_without_issues_key(): out = parse_conclusion('{"summary": "无问题"}', log=QUIET) assert out["summary"] == "无问题" assert out["issues"] == [] def test_parse_bare_array_becomes_issues(): out = parse_conclusion('[{"message": "a"}, {"message": "b"}]', log=QUIET) assert out["summary"] == "" assert [i["message"] for i in out["issues"]] == ["a", "b"] def test_parse_filters_non_dict_issues(): out = parse_conclusion('{"summary": "s", "issues": [{"message": "ok"}, "junk", 3]}', log=QUIET) assert out["issues"] == [{"message": "ok"}] def test_parse_degrades_prose_to_summary(): logs = [] out = parse_conclusion("未发现明显问题。", log=logs.append) assert out["summary"] == "未发现明显问题。" assert out["issues"] == [] assert any("降级" in line for line in logs) def test_parse_degrades_empty_text(): out = parse_conclusion("", log=QUIET) assert out["summary"] == "" and out["issues"] == [] # --------------------------------------------------------------------------- # collect_known_handles # --------------------------------------------------------------------------- def test_collect_known_handles_from_all_sources(): ev = make_evidence( entities={"total": 1, "items": [{"handle": "AA"}]}, texts={"total": 1, "items": [{"handle": "BB"}]}, dimensions={"total": 1, "items": [{"handle": "CC"}]}, blocks={"total": 1, "items": [{"handle": "DD"}]}, polylines={"total": 1, "items": [{"handle": "EE"}]}, ) assert collect_known_handles(ev) == {"AA", "BB", "CC", "DD", "EE"} def test_collect_known_handles_uppercases(): ev = make_evidence(entities={"items": [{"handle": "2f3a"}]}) assert collect_known_handles(ev) == {"2F3A"} def test_collect_known_handles_includes_extra(): ev = make_evidence() assert collect_known_handles(ev, extra_handles=["Ff", None, ""]) == {"FF"} def test_collect_known_handles_tolerates_non_dict_envelope(): # result_json 解析失败会返回原始字符串 assert collect_known_handles({"entities": "oops", "texts": None}) == set() def test_collect_known_handles_skips_items_without_handle(): ev = make_evidence(entities={"items": [{"type": "LINE"}, {"handle": ""}]}) assert collect_known_handles(ev) == set() # --------------------------------------------------------------------------- # normalize_issue # --------------------------------------------------------------------------- def test_normalize_llm_issue_with_known_handle_is_verified(): out = normalize_issue( {"category": "文字", "severity": "high", "message": "重叠", "handle": "2f3a"}, frame_index=1, source="llm", known_handles={"2F3A"}, validate_handles=True, ) assert out["handle"] == "2f3a" # 保留原样(大小写归一仅用于比对) assert out["verified"] is True assert out["source"] == "llm" assert out["frame"] == 1 def test_normalize_llm_issue_with_unknown_handle_strips_it(): out = normalize_issue( {"message": "疑似问题", "handle": "DEAD"}, frame_index=2, source="llm", known_handles={"2F3A"}, validate_handles=True, ) assert out["handle"] is None assert out["verified"] is False assert out["message"] == "疑似问题" # 问题保留 def test_normalize_deterministic_never_stripped(): out = normalize_issue( {"message": "零长度", "handle": "ZZZZ"}, frame_index=1, source="deterministic", known_handles=set(), validate_handles=True, ) assert out["handle"] == "ZZZZ" and out["verified"] is True def test_normalize_validation_disabled_keeps_handle(): out = normalize_issue( {"message": "x", "handle": "DEAD"}, frame_index=1, source="llm", known_handles=set(), validate_handles=False, ) assert out["handle"] == "DEAD" and out["verified"] is True def test_normalize_llm_issue_without_handle_is_verified(): out = normalize_issue({"message": "整体偏空"}, frame_index=1, source="llm", known_handles=set(), validate_handles=True) assert out["handle"] is None and out["verified"] is True def test_normalize_falls_back_on_invalid_severity_and_category(): out = normalize_issue({"severity": "critical", "message": "x"}, frame_index=1, source="llm", known_handles=set(), validate_handles=True) assert out["severity"] == "low" assert out["category"] == "图面整体" def test_normalize_preserves_handles_list(): out = normalize_issue({"message": "重复组", "handle": "A", "handles": ["A", "B"]}, frame_index=1, source="deterministic", known_handles=set(), validate_handles=True) assert out["handles"] == ["A", "B"] # --------------------------------------------------------------------------- # build_finding # --------------------------------------------------------------------------- def test_build_finding_merges_deterministic_then_llm(): ev = make_evidence(index=3, entities={"items": [{"handle": "AA"}]}) checks = [{"category": "几何", "severity": "medium", "message": "零长度", "handle": "ZZ"}] conclusion = { "summary": "有 2 处问题", "issues": [ {"category": "文字", "severity": "high", "message": "重叠", "handle": "AA"}, {"category": "几何", "severity": "low", "message": "臆测", "handle": "NOPE"}, ], } finding = build_finding(ev, conclusion, checks, validate_handles=True, log=QUIET) assert finding["frame"] == 3 assert finding["sheet"] == "A1" assert finding["summary"] == "有 2 处问题" assert [i["source"] for i in finding["issues"]] == ["deterministic", "llm", "llm"] assert finding["counts"] == {"deterministic": 1, "llm": 2, "unverified": 1} # 被剥掉 handle 的那条仍在 assert finding["issues"][2]["verified"] is False def test_build_finding_deterministic_handle_counts_as_known(): # 确定性检查命中的 handle 应进入已知集合,供同框 LLM 引用 ev = make_evidence(entities={"items": []}) checks = [{"message": "零长度", "handle": "ZZ"}] conclusion = {"summary": "", "issues": [{"message": "同一条", "handle": "zz"}]} finding = build_finding(ev, conclusion, checks, validate_handles=True, log=QUIET) assert finding["issues"][1]["verified"] is True def test_build_finding_without_checks_or_conclusion(): finding = build_finding(make_evidence(), {}, None, log=QUIET) assert finding["issues"] == [] assert finding["counts"] == {"deterministic": 0, "llm": 0, "unverified": 0} # --------------------------------------------------------------------------- # flatten_issues # --------------------------------------------------------------------------- def test_flatten_issues_concatenates(): findings = [ {"issues": [{"message": "a"}, {"message": "b"}]}, {"issues": [{"message": "c"}]}, {}, ] assert [i["message"] for i in flatten_issues(findings)] == ["a", "b", "c"] def test_flatten_issues_empty(): assert flatten_issues([]) == [] assert flatten_issues(None) == []