diff --git a/resources/tester/fixtures/base_data_source_known.json b/resources/tester/fixtures/base_data_source_known.json new file mode 100644 index 00000000..0710669c --- /dev/null +++ b/resources/tester/fixtures/base_data_source_known.json @@ -0,0 +1,116 @@ +{ + "_설명": [ + "기초데이터 열(품셈 근거) ↔ 원문 — 지금 알고 있는 어긋남(2026-09-18 랩탑 메인이 시험으로 처음 뜸).", + "시험: resources/tester/test_base_data_source_match.py — 이 목록 밖에서 어긋나면 빨강.", + "고치면 지울 것: python resources/tester/test_base_data_source_match.py --prune (지우기만 함).", + "md=원문에서 읽은 값 · data=자료 값 · why=사람이 적은 까닭(시험은 맞대기에서 뺌).", + "못 재는 칸은 시험 파일 NOT_CHECKED 에 까닭과 함께." + ], + "coef": { + "coef_soil_C/11": { + "md": [ + "암괴나호박돌이섞인모래", + 0.9, + 0.95, + null + ], + "data": [ + "암괴ㆍ호박돌섞인모래", + 0.9, + 0.95, + null + ], + "why": "위와 같음" + }, + "coef_soil_L/11": { + "md": [ + "암괴나호박돌이섞인모래", + 1.4, + 1.45, + null + ], + "data": [ + "암괴ㆍ호박돌섞인모래", + 1.4, + 1.45, + null + ], + "why": "이름을 「암괴ㆍ호박돌 섞인 모래」로 줄여 적음 — 값은 원문과 같음" + } + }, + "material_surcharge": { + "이형철근": { + "md": [ + "3", + "6-7" + ], + "data": [ + 3, + 7 + ], + "why": "원문 복잡 구조물 주철근 「6-7」 범위 중 위 끝 7 을 씀 — 범위를 살릴지 사람 확인" + } + }, + "formwork_reuse": { + "reuse_ratio_pct/timber": { + "md": { + "각재": "비율 칸 비어 있음", + "합판": { + "재료별(%)": [ + "100.0", + "57.0", + "46.1", + "40.1", + "37.1", + "34.7" + ], + "노무비(%)": [ + "100.0", + "60.0", + "47.1", + "40.0", + "34.2", + "32.0" + ] + } + }, + "data": [ + 100.0, + 60.0, + 47.1, + 40.0, + 34.2, + 32.0 + ], + "why": "⚠ 원문 12-4 는 각재 줄 비율 칸이 비어 있음 — 이 여섯 값은 합판 줄 「노무비(%)」 열인데 각재(timber)라는 이름이 붙음 · 쓰는 곳 확인 필요" + } + }, + "rebar_complexity": { + "classes/복잡": { + "md": "12-3 [주]① 에 없는 예시", + "data": [ + "교량 슬래브" + ], + "why": "원문 「교량의 슬래브」를 줄여 적음" + } + }, + "timber_structure_class": {}, + "masonry_slope": {}, + "masonry_back_length": {}, + "stone_kind": {}, + "masonry_class": {}, + "machine_productivity": { + "dump_material/발파암/truck_efficiency": { + "md": [], + "data": "0.9", + "why": "10-12-3 운반 식에 E 가 없음 — 토사·암절취의 0.9 를 이어 씀" + }, + "dump_material/토사/loading_efficiency": { + "md": [ + 0.75 + ], + "data": "0.60", + "why": "본문 E0=0.75 대신 10-12-1 [주]⑤ 표 「임도 0.60(불량)」 을 씀 — 자료 loading_note 에 사유" + } + } +} diff --git a/resources/tester/test_base_data_source_match.py b/resources/tester/test_base_data_source_match.py new file mode 100644 index 00000000..979504d9 --- /dev/null +++ b/resources/tester/test_base_data_source_match.py @@ -0,0 +1,556 @@ +"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 2026-09-18 브레인 일감(랩탑 메인). + +기초값 열다섯 중 **품셈 원문에서 옮겨 적은 열**을 원문 표와 칸 단위로 맞댐. 나머지 다섯 +(노임·기계·자재·유가·요율)은 `test_base_prices_source_match.py` 몫. + +- 원문 = 산림 품셈 md(`test_work_item_master_source_match.MD`) · 건설 품셈 제8장 md(불도저 제원) +- 어긋남은 `fixtures/base_data_source_known.json` 에 — 목록 밖 새 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강 + (고친 뒤 `python resources/tester/test_base_data_source_match.py --prune`, 지우기만 함) +- ⚠ **못 재는 칸은 못 잰다고 적음** — `NOT_CHECKED` 에 까닭과 함께. 자료에 새 칸이 생기면 빨강 + (재든지 못 잰다고 적든지 하게). +- 표 읽기는 옆 시험의 `md_tables` 한 벌 — 빌더 코드는 안 씀. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest + +from test_work_item_master_source_match import MD, ROOT, _without_why, md_tables + +KNOWN = Path(__file__).resolve().parent / "fixtures/base_data_source_known.json" +CONST_MACHINE_MD = ( + ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md" +) +FILES = { + "coef": "data_cost_input_value/coef_2026.json", + "material_surcharge": "data_material_surcharge/material_surcharge_2026-01-01.json", + "formwork_reuse": "data_formwork/formwork_reuse_2026-01-01.json", + "rebar_complexity": "data_rebar/rebar_complexity_2026-01-01.json", + "timber_structure_class": "data_timber/timber_structure_class_2026-01-01.json", + "masonry_slope": "data_masonry/masonry_slope_2026-01-01.json", + "masonry_back_length": "data_masonry/masonry_back_length_2026-01-01.json", + "stone_kind": "data_masonry/stone_kind_2026-01-01.json", + "masonry_class": "data_masonry/masonry_class_2026-01-01.json", + "machine_productivity": "data_machine_productivity/machine_productivity_2026-01-01.json", +} + +#: 값이 아니라 글·방침·출처 칸 — 맞댈 값이 없음. +TEXT_FIELDS = { + "schema_version", "dataset_id", "effective_date", "generated_at", "note", "why", "source", + "sources", "policy", "not_here", "no_folding", "option_key", "option_note", "original_tables", + "not_found", "candidates_pending_user", "observed_practice", "pending_user", +} # fmt: skip +#: 재는 칸. +CHECKED = { + "coef": {"variables"}, + "material_surcharge": {"rates_pct"}, + "formwork_reuse": {"reuse_by_class", "reuse_ratio_pct", "euroform_type"}, + "rebar_complexity": {"classes"}, + "timber_structure_class": {"classes"}, + "masonry_slope": {"steps_m", "table"}, + "masonry_back_length": {"steps_m", "table_cm"}, + "stone_kind": {"kinds", "wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2", "back_lengths_cm"}, + "masonry_class": {"back_length", "boulder_diameter", "bond"}, + "machine_productivity": {"variables"}, +} +#: ⚠ 못 재는 칸 — 까닭. +NOT_CHECKED = { + "formwork_reuse": { + "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + "shoring": "강관동바리 대상 메모 — 값 없음", + }, + "rebar_complexity": { + "form_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + "price_hint_krw_per_ton": "B09 가 자재 단가로 셈한 참고값 — 원문에 없음", + }, + "timber_structure_class": { + "basis_unit": "밑수 풀이 글(「목재 채적 ㎥」) — 원문은 「㎥당」만 적음", + "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + }, + "stone_kind": { + "backfill_ratio_of_back_length": "교본 7-3 값(1/3·1/2) — 품셈 원문이 아님(교본 원본 대조 몫)", + "fallback": "건설품셈 [참고자료] 돌쌓기 규격별 소요량 — 원문 md 가 이 저장소에 없음", + }, + "masonry_class": { + "face_slope": "교본 7-3 기준(지식DB 돌쌓기.md 요약) — 품셈 원문이 아님", + }, +} + + +# ── 원문 읽기 ──────────────────────────────────────────────────────────── +def _sq(text: str) -> str: + return re.sub(r"\s+", "", str(text)) + + +def _num(text: str) -> float | None: + """`0.30` · `1:0.30` → 수. `-`·빈칸 → `None`.""" + t = _sq(text).split(":")[-1].replace(",", "") + return float(t) if re.fullmatch(r"\d+(\.\d+)?", t) else None + + +def _range(text: str) -> tuple[float | None, float | None]: + """`1.70∼2.00` · `25~35` · `6-7` · `75이상` → (아래, 위).""" + t = _sq(text) + if m := re.fullmatch(r"(\d+(?:\.\d+)?)이상", t): + return float(m.group(1)), None + parts = re.split(r"[∼~~-]", t) + if len(parts) == 2 and all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts): + return float(parts[0]), float(parts[1]) + return None, None + + +def _numbers(text: str) -> set[float]: + return {float(x) for x in re.findall(r"\d+(?:\.\d+)?", text)} + + +def _section(lines: list[str], heading: str) -> str: + """`### {heading}` 부터 다음 같은 급 제목 앞까지 글.""" + start = next(i for i, x in enumerate(lines) if x.startswith(f"### {heading}")) + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("### ")), len(lines)) + return "\n".join(lines[start:end]) + + +def _f(value) -> float | None: + return None if value is None else float(value) + + +# ── 열마다 맞대기 — {열쇠: {"md": 원문, "data": 자료}} ───────────────────── +def _coef(doc: dict, md: dict) -> dict: + out = {} + rows = md["tables"]["F0004"]["rows"] + for col, var in ((1, "coef_soil_L"), (2, "coef_soil_C")): + records = doc["variables"][var]["records"] + if len(records) != len(rows): + out[f"{var}/줄 수"] = {"md": len(rows), "data": len(records)} + for i, (row, rec) in enumerate(zip(rows, records)): + name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(row[0])) + lo, hi = _range(row[col]) + md_val = [name, lo, hi, None if lo is not None else _sq(row[col])] + data_name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(rec["soil_type"])) + data_val = [ + data_name, + _f(rec.get("min")), + _f(rec.get("max")), + rec.get("rule") and _sq(rec["rule"]), + ] + if md_val != data_val: + out[f"{var}/{i + 1}"] = {"md": md_val, "data": data_val} + # 원문 보관 표 — 원문 표와 글자까지 같아야 함 + by_line = {t["line"]: t for t in md["tables"].values()} + for var in ("surcharge_labor", "surcharge_mat"): + for t in doc["variables"][var]["tables"]: + m = by_line.get(t["line"]) + if m is None or [m["headers"], m["rows"]] != [t["headers"], t["rows"]]: + out[f"{var}/L{t['line']}"] = { + "md": m and [m["headers"], m["rows"]], + "data": [t["headers"], t["rows"]], + } + tool = doc["variables"]["rate_tool"] + if not {tool["min_percent"], tool["max_percent"]} <= _numbers(_section(md["lines"], "1-2-6.")): + out["rate_tool"] = { + "md": "1-2-6 글에 없음", + "data": [tool["min_percent"], tool["max_percent"]], + } + return out + + +#: 자재 → 원문 칸 자리 (표, 줄, 칸, 줄에 있어야 할 글). 줄이 밀리면 글 확인에서 걸림. +SURCHARGE_CELLS = { + "시멘트": [("F0008", 0, 1, "시멘트"), ("F0008", 0, 2, "시멘트")], + "잔골재": [("F0008", 1, 1, "잔골재"), ("F0008", 1, 2, "잔골재")], + "채움재": [("F0008", 1, 1, "채움재"), ("F0008", 1, 2, "채움재")], + "굵은골재": [("F0008", 2, 1, "굵은골재"), ("F0008", 2, 2, "굵은골재")], + "모래": [("F0009", 0, 1, "모래")], + "부순돌": [("F0009", 1, 1, "부순돌")], + "자갈": [("F0009", 1, 1, "자갈")], + "점질토": [("F0009", 3, 1, "점질토")], + "이형철근": [("F0011", 1, 1, "이형철근"), ("F0011", 2, 1, "주철근")], + "원형철근": [("F0011", 0, 1, "원형철근")], + "강판": [("F0011", 3, 1, "강판")], + "각재": [("F0012", 0, 2, "각재")], + "판재": [("F0012", 1, 1, "판재")], + "레미콘": [("F0012", 5, 2, "레디믹스트"), ("F0012", 6, 1, "철근구조물")], + "흄관": [("F0012", 16, 1, "원심력철근콘크리트관")], + "떼": [("F0012", 10, 1, "떼")], + "초화류": [("F0012", 10, 1, "초화류")], + "사방용 수목": [("F0012", 9, 1, "사방용수목")], + "원석": [("F0012", 8, 1, "마름돌용")], +} + + +def _material_surcharge(doc: dict, md: dict) -> dict: + out = {} + for rec in doc["rates_pct"]: + cells = SURCHARGE_CELLS.get(rec["material"]) + data_val = [rec.get("rate"), rec.get("alt_rate")] + if cells is None: + out[rec["material"]] = {"md": "원문 자리 모름", "data": data_val} + continue + md_val = [] + for table, r, c, label in cells: + row = md["tables"][table]["rows"][r] + md_val.append(row[c] if label in _sq("".join(row)) else f"줄 밀림({label})") + md_val += [None] * (2 - len(md_val)) + norm = [_num(v) if v and _num(v) is not None else v for v in md_val] + if norm != [_f(v) for v in data_val]: + out[rec["material"]] = {"md": md_val, "data": data_val} + return out + + +def _formwork_reuse(doc: dict, md: dict) -> dict: + out = {} + rows = md["tables"]["F0040"]["rows"] + for rec, row in zip(doc["reuse_by_class"], rows): + if [int(_num(row[0][:-1])), rec["class"] in row[1]] != [rec["reuse_count"], True]: + out[f"reuse_by_class/{rec['class']}"] = { + "md": row, + "data": [rec["reuse_count"], rec["class"]], + } + t = md["tables"]["F0336"] + heads = t["rows"][0] # 횟수별 | 재료별(%) | 노무비(%) + series = {} + for row in t["rows"]: + label = _sq(row[0]) + series[label] = {heads[1]: row[4].split(), heads[2]: row[5].split()} + ratio = doc["reuse_ratio_pct"] + for key, row_label in (("plywood", "합판"), ("timber", "각재")): + data_val = [ratio[key][str(n)] for n in range(1, 7)] + found = {h: [float(x) for x in v] for h, v in series.get(row_label, {}).items() if v} + if data_val not in found.values(): + out[f"reuse_ratio_pct/{key}"] = { + "md": {row_label: found or "비율 칸 비어 있음", "합판": series["합판"]}, + "data": data_val, + } + area = md["tables"]["F0395"]["rows"] + md_area = dict(zip([_sq(x) for x in area[0][:3]], [_num(x) for x in area[1][3:6]])) + for cls in doc["euroform_type"]["classes"]: + if md_area.get(cls["key"]) != cls["daily_area_m2"]: + out[f"euroform_type/{cls['key']}"] = { + "md": md_area.get(cls["key"]), + "data": cls["daily_area_m2"], + } + return out + + +def _rebar_complexity(doc: dict, md: dict) -> dict: + """예시마다 12-3 [주]① 글에 있나. + + ponytail: 글 포함만 봄 — 원문에 있는 낱말(「교량」)을 엉뚱한 갈래에 보태도 통과. 갈래별 문장 쪼개기는 틀릴 때. + """ + note = _sq(md["notes_12_3"]) + out = {} + for cls in doc["classes"]: + missing = [ex for ex in cls["examples"] if _sq(ex) not in note] + if missing: + out[f"classes/{cls['key']}"] = {"md": "12-3 [주]① 에 없는 예시", "data": missing} + return out + + +def _timber_structure_class(doc: dict, md: dict) -> dict: + rows, found, group = md["tables"]["F0440"]["rows"], {}, "" + for row in rows: + cells = [_sq(c) for c in row] + if cells[0].endswith("구조"): + group, cells = cells[0], cells[1:] + grade = "" if _num(cells[0]) is not None else cells.pop(0) + found[f"{group} {grade}".strip()] = [_num(cells[0]), _num(cells[1])] + out = {} + data = {c["key"]: [c["carpenter"], c["laborer"]] for c in doc["classes"]} + for key in sorted(set(found) | set(data)): + if found.get(key) != data.get(key): + out[f"classes/{key}"] = {"md": found.get(key), "data": data.get(key)} + return out + + +def _steps(cells: list[str]) -> list[float]: + return [ + float(_sq(c).lstrip("∼~~")) for c in cells if re.fullmatch(r"[∼~~]\d+(\.\d+)?", _sq(c)) + ] + + +def _masonry_slope(doc: dict, md: dict) -> dict: + t = md["tables"]["F0413"] + found, group = {}, "" + for row in t["rows"]: + cells = [c for c in row] + if _sq(cells[0]) in ("메쌓기", "찰쌓기"): + group, cells = _sq(cells[0]), cells[1:] + found[f"{group}/{_sq(cells[0])}"] = [_num(c) for c in cells[1:] if _sq(c)] + data = {f"{g}/{f}": v for g, faces in doc["table"].items() for f, v in faces.items()} + out = { + k: {"md": found.get(k), "data": data.get(k)} + for k in sorted(set(found) | set(data)) + if found.get(k) != data.get(k) + } + if _steps(t["headers"]) != doc["steps_m"]: + out["steps_m"] = {"md": _steps(t["headers"]), "data": doc["steps_m"]} + return out + + +def _masonry_back_length(doc: dict, md: dict) -> dict: + t = md["tables"]["F0412"] + labels = [re.sub(r"\(.*", "", x) for x in t["rows"][1][0].split()] # 메쌓기(㎝) 찰쌓기(㎝) + found = {label: [] for label in labels} + for cell in t["rows"][1][1:6]: + for label, part in zip(labels, cell.split()): + lo, hi = _range(part) + found[label].append([lo, hi]) + out = {} + for label in sorted(set(found) | set(doc["table_cm"])): + data = [[_f(a), _f(b)] for a, b in doc["table_cm"].get(label, [])] + if found.get(label) != data: + out[f"table_cm/{label}"] = {"md": found.get(label), "data": doc["table_cm"].get(label)} + if _steps(t["rows"][0]) != doc["steps_m"]: + out["steps_m"] = {"md": _steps(t["rows"][0]), "data": doc["steps_m"]} + return out + + +def _stone_grid(table: dict) -> dict[str, dict[str, float | None]]: + """뭉친 칸 표(`야면석(㎥) 깬잡석(㎥)` | `0.06 0.09`) → {돌: {뒷길이: 값}}.""" + lengths = [re.sub(r"[^\d]", "", h) for h in table["headers"][1:] if re.search(r"\d+㎝", h)] + grid = {} + for row in table["rows"]: + names = [re.sub(r"\(.*", "", n) for n in re.findall(r"[가-힣]+\s*[가-힣]*\(㎥\)", row[0])] + names = [_sq(n) for n in names] + for name in names: + grid[name] = {} + for length, cell in zip(lengths, row[1:]): + for name, part in zip(names, cell.split()): + grid[name][length] = _num(part) + return grid + + +def _stone_kind(doc: dict, md: dict) -> dict: + out = {} + first = {"야면석·호박돌": "야면석", "깬잡석": "깬잡석", "깬돌": "깬돌", "견치돌": "견치돌"} + for field, table in (("wedge_stone_m3_per_m2", "F0408"), ("fill_concrete_m3_per_m2", "F0410")): + grid = _stone_grid(md["tables"][table]) + fill_rows = { + "깬돌": "깬돌", + "견치돌": "견치돌", + "깬잡석": "깬잡석", + "야면석·호박돌": "야면석", + } + for kind in doc["kinds"]: + name = first[kind] if field.startswith("wedge") else fill_rows[kind] + data = {k: _f(v) for k, v in doc[field][kind].items()} + if grid.get(name) != data: + out[f"{field}/{kind}"] = {"md": grid.get(name), "data": doc[field][kind]} + if field.startswith("fill") and grid.get("야면석") != grid.get("호박돌"): + out[f"{field}/호박돌"] = {"md": grid.get("호박돌"), "data": "야면석과 한 줄로 묶음"} + lengths = [int(re.sub(r"[^\d]", "", h)) for h in md["tables"]["F0408"]["headers"][1:]] + if lengths != doc["back_lengths_cm"]: + out["back_lengths_cm"] = {"md": lengths, "data": doc["back_lengths_cm"]} + if [first[k] for k in doc["kinds"]] != list(_stone_grid(md["tables"]["F0408"])): + out["kinds"] = {"md": list(_stone_grid(md["tables"]["F0408"])), "data": doc["kinds"]} + return out + + +def _masonry_class(doc: dict, md: dict) -> dict: + out = {} + back = [ + int(_numbers(c).pop()) for c in md["tables"]["F0407"]["rows"][0] if _sq(c).endswith("이하") + ] + if back != [c["max_cm"] for c in doc["back_length"]["classes"]]: + out["back_length"] = { + "md": back, + "data": [c["max_cm"] for c in doc["back_length"]["classes"]], + } + boulder = [] + for cell in md["tables"]["F0419"]["rows"][0]: + if nums := re.findall(r"\d+", cell): + boulder.append(f"{nums[0]}~{nums[1]}") + if boulder != doc["boulder_diameter"]["classes"]: + out["boulder_diameter"] = {"md": boulder, "data": doc["boulder_diameter"]["classes"]} + for name, code in doc["bond"]["codes"].items(): + number = code.removeprefix("FP-").replace("-0", "-").lstrip("0") + if not any(x.strip() == f"### {number}. {name}" for x in md["lines"]): + out[f"bond/{name}"] = {"md": f"### {number}. {name} 제목 없음", "data": code} + return out + + +def _machine_productivity(doc: dict, md: dict) -> dict: + out, var = {}, doc["variables"] + speed = {} # (궤도, 톤, 단) → [전진, 후진] + for track, table in zip(("무한궤도", "타이어"), md["dozer_tables"]): + gears = [_sq(c) for c in table["rows"][0]] + fwd = [i for i, h in enumerate(table["headers"]) if "전진" in h][0] + rev = [i for i, h in enumerate(table["headers"]) if "후진" in h][0] + for row in table["rows"][1:]: + ton = re.match(r"\d+", row[0]).group() + for g in range(1, rev - fwd + 1): + f, r = ( + _num(row[fwd + g - 1]), + _num(row[rev + g - 1]) if rev + g - 1 < len(row) else None, + ) + if f is not None and r is not None: + speed[(track, ton, g)] = [f, r] + assert gears[fwd] == "1단" or gears[fwd].startswith("1") + data = { + (r["track"], r["tonnage_ton"], r["gear"]): [ + float(r["forward_m_per_min"]), + float(r["reverse_m_per_min"]), + ] + for r in var["dozer_speed"]["records"] + } + for key in sorted(set(speed) | set(data)): + if speed.get(key) != data.get(key): + out["dozer_speed/" + "/".join(map(str, key))] = { + "md": speed.get(key), + "data": data.get(key), + } + # 삽날 용량 — 원문 md 가 표를 한 줄로 뭉갬(`0.51.11.5…`) · 톤 차례는 속도 표 둘에서 옴 + tons = sorted({int(k[1]) for k in speed} | {int(k[1]) for k in data}) + line = next(x for x in md["const_lines"] if "q" in x and "무한궤도" in x and "타 이 어" in x) + blades = {} + for track, part in zip( + ("무한궤도", "타이어"), re.split(r"타\s*이\s*어", line.split("무한궤도", 1)[1]) + ): + for ton, token in zip(tons, re.findall(r"\d\.\d|-", part)): + if token != "-": + blades[(track, str(ton))] = float(token) + data = { + (r["track"], r["tonnage_ton"]): float(r["blade_m3"]) for r in var["dozer_blade"]["records"] + } + for key in sorted(set(blades) | set(data)): + if blades.get(key) != data.get(key): + out["dozer_blade/" + "/".join(key)] = {"md": blades.get(key), "data": data.get(key)} + # 덤프 — 원문이 표가 아니라 식 글(`t3=1.1`). **기호 자리**에 적힌 수와 맞댐 + haul = _section(md["lines"], "10-12-1.") + for r in var["dump_haul"]["records"]: + found = _symbol_values(haul, DUMP_HAUL_SYMBOLS[r["key"]]) + if float(r["value"]) not in found: + out[f"dump_haul/{r['key']}"] = {"md": sorted(found), "data": r["value"]} + for i, r in enumerate(var["dump_material"]["records"], 1): + text = _section(md["lines"], f"10-12-{i}.") + for field, pattern in DUMP_MATERIAL_SYMBOLS.items(): + found = _symbol_values(text, pattern) + if float(r[field]) not in found: + out[f"dump_material/{r['label']}/{field}"] = {"md": sorted(found), "data": r[field]} + return out + + +#: 덤프 밑값 → 10-12-1 글의 기호 자리. +DUMP_HAUL_SYMBOLS = { + "truck_ton": r"덤프트럭\((\d+)ton\)", + "bucket_m3": r"q0=([\d.]+)", + "loader_cycle_sec": r"㎝s=(\d+)", + "speed_loaded_kmh": r"V1\(적재\)\s*\|\s*([\d.]+)", + "speed_empty_kmh": r"V2\(공차\)\s*\|\s*([\d.]+)", + "unload_min": r"t3=([\d.]+)", + "wait_min": r"t4=([\d.]+)", + "cover_min": r"t5=([\d.]+)", + "loading_cycle_sec": r"㎝=(\d+)\(180°\)", +} +#: 재료별 값 → 그 재료 절(10-12-1·2·3) 글의 기호 자리. `E=` 는 `Es=`·`E0=` 와 가름. +DUMP_MATERIAL_SYMBOLS = { + "unit_weight_ton_per_m3": r"γt=([\d.]+)", + "loose_factor": r"f=1/([\d.]+?)=?[,\s]", + "bucket_factor": r"(? set[float]: + return {float(m.rstrip(".")) for m in re.findall(pattern, text)} + + +CHECKS = { + "coef": _coef, + "material_surcharge": _material_surcharge, + "formwork_reuse": _formwork_reuse, + "rebar_complexity": _rebar_complexity, + "timber_structure_class": _timber_structure_class, + "masonry_slope": _masonry_slope, + "masonry_back_length": _masonry_back_length, + "stone_kind": _stone_kind, + "masonry_class": _masonry_class, + "machine_productivity": _machine_productivity, +} + + +# ── 맞대기 ────────────────────────────────────────────────────────────── +def load_sources() -> dict: + lines = MD.read_text(encoding="utf-8").splitlines() + tables = {t["id"]: t for t in md_tables(lines)} + const_lines = CONST_MACHINE_MD.read_text(encoding="utf-8").splitlines() + dozer = [t for t in md_tables(const_lines) if "전진속도" in "".join(t["headers"])][:2] + notes_12_3 = [] + for x in lines[tables["F0335"]["end"] :]: + if x.strip().startswith("[주]"): + notes_12_3.append(x) + break + return { + "md": { + "lines": lines, + "tables": tables, + "const_lines": const_lines, + "dozer_tables": dozer, + "notes_12_3": " ".join(notes_12_3), + }, + "docs": { + k: json.loads((ROOT / "resources" / p).read_text(encoding="utf-8")) + for k, p in FILES.items() + }, + } + + +def current_mismatches(src: dict) -> dict[str, dict]: + return {kind: check(src["docs"][kind], src["md"]) for kind, check in CHECKS.items()} + + +def load_known() -> dict[str, dict]: + return json.loads(KNOWN.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def src() -> dict: + return load_sources() + + +@pytest.mark.parametrize("kind", list(FILES)) +def test_칸마다_재거나_못잰다고_적힘(src: dict, kind: str) -> None: + """자료의 칸이 셋 중 하나 — 재는 칸 · 못 재는 칸(까닭) · 글 칸. 새 칸이 생기면 빨강.""" + fields = set(src["docs"][kind]) - TEXT_FIELDS + declared = CHECKED[kind] | set(NOT_CHECKED.get(kind, {})) + assert fields == declared, ( + f"{kind} — 안 적힌 칸 {sorted(fields - declared)} · 없는 칸 {sorted(declared - fields)}" + ) + + +@pytest.mark.parametrize("kind", list(FILES)) +def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: + """알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강(`why` 는 뺌).""" + now = current_mismatches(src)[kind] + known = {k: _without_why(v) for k, v in load_known()[kind].items()} + msgs = [ + f" 새로 어긋남 {k}: {json.dumps(now[k], ensure_ascii=False)}" + for k in sorted(set(now) - set(known)) + ] + msgs += [ + f" 모습 바뀜 {k}: 목록 {json.dumps(known[k], ensure_ascii=False)}\n 지금 {json.dumps(now[k], ensure_ascii=False)}" + for k in sorted(set(now) & set(known)) + if now[k] != known[k] + ] + msgs += [f" 고쳐짐 {k} — 목록에서 지울 것(--prune)" for k in sorted(set(known) - set(now))] + assert not msgs, f"{kind} {len(msgs)}건 — 원문 ↔ 자료:\n" + "\n".join(msgs) + + +if __name__ == "__main__" and "--prune" in sys.argv: + # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). + current = current_mismatches(load_sources()) + data = load_known() + for kind in FILES: + before = len(data[kind]) + data[kind] = { + k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v) + } + print(f"{kind}: {before} → {len(data[kind])}") + KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")