Files
Aislo/resources/tester/test_base_data_source_match.py
T
eomsangdonandClaude Opus 5 fdc81e7520 test(z01): 기초데이터 열(품셈 근거) 원문 맞대기 — 칸 단위 대조 · 못 재는 칸은 까닭과 함께
브레인 일감 ③ 앞 절반. 기초값 열다섯 중 품셈 원문에서 옮겨 적은 열을 원문 표와 칸 단위로 맞댐
(토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목구조 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래 · 기계 작업량).

- 원문 = 산림 품셈 md 표(F0004·F0008~12·F0040·F0336·F0395·F0407·F0412·F0413·F0408·F0410·F0419·F0440 ·
  12-3 [주]①) · 10-12 덤프 식 글의 기호 자리(`t3=`·`γt=`·`E0=`) · 건설 품셈 제8장 md 불도저 속도·삽날
- 어긋남 7 → 알려진 목록(`fixtures/base_data_source_known.json`) 에 까닭과 함께
  · ⚠ 거푸집 전용 비율 「timber(각재)」 여섯 값 = 원문 12-4 합판 줄 「노무비(%)」 열 — 원문엔 각재 비율이 없음
  · 이형철근 주철근 원문 「6-7」 → 7 · 덤프 토사 E0 본문 0.75 대신 표 0.60 · 발파암 운반 E 원문에 없음(0.9 이어 씀)
  · 이름 줄임 3(「암괴나 호박돌이 섞인 모래」 L·C 둘 · 「교량의 슬래브」)
- 칸마다 「잰다 · 못 잰다(까닭) · 글」 중 하나로 적힘 — 새 칸이 생기면 빨강
  · 못 잼: 교본 값(뒤채움 몫 · 전면 기울기) · 건설품셈 참고자료(원문 md 없음) · 프로그램 이음 칸 · B09 계산값
- 열마다 값을 하나씩 바꿔 빨강 확인 · 못 잡는 것 하나: 철근 예시는 글 포함만 봄(「교량」처럼 원문에 있는 낱말을 보태면 통과)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-18 00:42:02 +09:00

557 lines
25 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.
"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 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.702.00` · `2535` · `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"(?<![A-Za-z0-9])K=([\d.]+)",
"loader_efficiency": r"Es=([\d.]+)",
"truck_efficiency": r"(?<![A-Za-z0-9])E=([\d.]+)",
"loading_efficiency": r"E0=([\d.]+)",
}
def _symbol_values(text: str, pattern: str) -> 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")