"""건설 공종 축(`CW-00001`) 뽑기 — 2026-09-17 브레인 8-2. 못박는 것 ① 목차 글줄 가르기 — 쪽 번호가 다음 번호에 붙은 꼴(「131-2-6」 = 13쪽 + 1-2-6) · 부문마다 장 번호 1부터 ② 표는 **본문 절 제목**으로 붙음 — 인용(「3-2-4 터파기(기계)’를 참고하여」)은 제목이 아님 ③ 목차 오기 없음 — 연혁 표시(「('20년 보완)」)가 붙은 본문 제목이 모두 목차 번호·이름과 같음 (산림에서 목차가 번호를 잘못 적어 표가 딴 줄에 붙은 병 · 건설도 같은 함정) ④ 못 붙인 표는 목록으로 · 열쇠 CW- 안 겹침 · 다시 뽑아도 같은 열쇠 ⑤ 산출 파일 = 지금 코드로 뽑은 것(산림도 같이 — 두 뽑기가 표 읽기 한 벌을 나눠 씀 · 금액 불변) """ from __future__ import annotations import json import re import pytest from B08_Quantity import B08_Quantity_Build_WorkItemMaster as forest from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Const as const OUT = forest.OUT_DIR CONST_MASTER = OUT / "const_work_item_master_2026-01-01.json" @pytest.fixture(scope="module") def built() -> tuple: return const.build_const() @pytest.fixture(scope="module") def master() -> dict: return json.loads(CONST_MASTER.read_text(encoding="utf-8")) def _same_except_time(fresh: dict, saved: dict) -> None: fresh = json.loads(json.dumps(fresh, ensure_ascii=False)) for doc in (fresh, saved): doc.pop("generated_at") doc["stats"].pop("keys_newly_issued") # 첫 뽑기만 발급 · 다시 뽑으면 0 doc["stats"].pop("variant_keys_newly_issued", None) # 갈래 열쇠도 같음 assert fresh == saved def test_목차_글줄_가르기_쪽_번호가_붙은_꼴() -> None: text = "제1장적용기준31-1 일반사항····31-1-1 목적····131-2-6 공구 및 경장비····19제2장가설공사352-1 가설물의 한도 ····35" assert const.toc_entries(text) == [ ("1", "1", "적용기준"), ("1", "1-1", "일반사항"), ("1", "1-1-1", "목적"), ("1", "1-2-6", "공구 및 경장비"), ("2", "2", "가설공사"), ("2", "2-1", "가설물의 한도"), ] assert const.toc_entries("제11장칠공사61111-1 공통사항····611")[1] == ("11", "11-1", "공통사항") def test_인용은_제목이_아님() -> None: names = {("공통부문", "3-2-4"): "터파기(기계)", ("공통부문", "3-2-5"): "되메우기"} chapters = {"2", "3", "4"} assert ( const.titles_in("’3-2-4 터파기(기계)’를 참고하여 적용한다.", "공통부문", chapters, names) == [] ) assert const.titles_in("…경우3-2-4 터파기(기계)('25년 신설)", "공통부문", chapters, names) == [ "3-2-4" ] assert const.titles_in("853-2-5 되메우기Q = n·q", "공통부문", chapters, names) == ["3-2-5"] def test_목차_부문_다섯_장_마흔다섯(built: tuple) -> None: items = built[0]["work_items"] roots = [it for it in items if it["parent_code"] is None] assert [r["name"] for r in roots] == list(const.DIVISIONS) assert sum(1 for it in items if it["level"] == 2) == 45 codes = [it["work_item_code"] for it in items] assert len(set(codes)) == len(codes) def test_표는_다_붙고_못_붙인_표는_목록(built: tuple) -> None: master = built[0] stats = master["stats"] # 2026-09-18 2,192 → 2,202 — 코덱스가 제8장 손료표 열(C2193~C2202)을 되살림 assert stats["tables_total"] == 2202 assert stats["tables_attached"] + len(master["orphan_tables"]) == stats["tables_total"] assert stats["tables_orphan"] == len(master["orphan_tables"]) == 0 def test_목차_오기_없음_연혁_붙은_본문_제목이_목차와_같음(built: tuple) -> None: """본문 제목 번호가 목차에 없거나 이름이 다르면 그 절 표가 **앞 절에 조용히 붙음** — 빨강.""" items = built[0]["work_items"] names = {(it["division"], it["number"]): it["name"] for it in items if it["level"] > 1} titled = re.compile(r"(\d+(?:-\d+)+)\s*([^’'\[\]()]{1,40}?)\s*\(\s*['‘’]\d\d") data = json.loads(const.SOURCE.read_text(encoding="utf-8")) files = sorted({t["source_file"] for t in data["variables"]["pum"]["tables"]}) seen, bad = 0, [] for source_file in files: division, chapter = const.file_place(source_file) chapters = {str(chapter - 1), str(chapter), str(chapter + 1)} for line in (const.ROOT / source_file).read_text(encoding="utf-8").splitlines(): for found in titled.finditer(line): raw, name = found.group(1), "".join(found.group(2).split()) number = next( ( raw[cut:] for cut in range(len(raw.split("-")[0])) if raw[cut:].split("-")[0] in chapters ), None, ) if number is None: continue seen += 1 toc = "".join(names.get((division, number), "").split()) if not toc or not (name.startswith(toc) or toc.startswith(name)): bad.append( f"{division} {number} 「{found.group(2)}」 목차 「{names.get((division, number))}」" ) assert seen > 600 assert not bad, bad def test_열쇠_CW_안_겹침_다시_뽑아도_같음(built: tuple, master: dict) -> None: keys = [it["work_item_key"] for it in master["work_items"]] assert all(re.fullmatch(r"CW-\d{5}", k) for k in keys) assert len(set(keys)) == len(keys) assert built[3][1] == [] # 장부가 있으니 새로 안 냄 assert [it["work_item_key"] for it in built[0]["work_items"]] == keys def test_산출_파일이_지금_코드로_뽑은_것(built: tuple, master: dict) -> None: _same_except_time(built[0], master) def test_산림_산출도_그대로_금액_불변() -> None: """표 읽기를 떼어내고(`_Table`) 건설과 나눠 써도 산림 산출은 한 글자도 안 바뀜.""" saved = json.loads((OUT / "work_item_master_2026-01-01.json").read_text(encoding="utf-8")) master, undetermined, basis_missing, _ = forest.build() _same_except_time(master, saved) for name, fresh in (("form_undetermined", undetermined), ("basis_missing", basis_missing)): saved_items = json.loads((OUT / f"{name}_2026-01-01.json").read_text(encoding="utf-8"))[ "items" ] assert json.loads(json.dumps(fresh, ensure_ascii=False)) == saved_items def test_산림을_고르는_쪽이_건설_파일을_안_집음() -> None: """⚠ `work_item_master_*` 로 끝 파일을 고르는 자리가 있음(B08 밑수 대조 · B09 근거표) — 건설 산출을 `work_item_master_const_…` 로 두면 그쪽이 건설을 집어 밑수 대조가 통째로 빠졌음(2026-09-17).""" from B08_Quantity.B08_Quantity_Engine_BasisUnit import load_master assert load_master()["dataset_id"] == "work_item_master_forest" assert not list(OUT.glob("work_item_master_*const*.json")) def test_건설_갈래도_이름을_다듬어도_같은_열쇠(master: dict) -> None: """④ 건설 갈래 펴기(2026-09-17) — 갈래 열쇠는 번호(`CW-00063#01`) · 자간·물결표를 손질해도 새로 안 남.""" import copy from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( assign_variant_keys, load_registry, ) items = master["work_items"] spread = [it for it in items if it.get("variants")] assert len(spread) > 100 and sum(len(it["variants"]) for it in spread) > 400 registry = load_registry(const.CONST_KEY_REGISTRY, const.KEY_PREFIX) nodes = copy.deepcopy(spread) for node in nodes: # 사람이 이름을 다듬은 날 — 자간 한 칸 · 물결표 다른 벌 node["variant_keys"] = [ " ".join(k.split()).replace("∼", "~") for k in node["variant_keys"] ] assert assign_variant_keys(nodes, registry) == [] # 새 번호 0 for before, after in zip(spread, nodes): assert [v["variant_key"] for v in after["variants"]] == [ v["variant_key"] for v in before["variants"] ] def test_자간_다듬기는_진짜_띄어쓰기를_안_붙임() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing assert tidy_spacing("측 량") == "측량" assert tidy_spacing("평 규 준 틀") == "평규준틀" assert tidy_spacing("굴 삭 기 (무한궤도)") == "굴삭기 (무한궤도)" assert tidy_spacing("메 붙 임 · 깬 돌 · 뒷길이 25㎝") == "메붙임 · 깬돌 · 뒷길이 25㎝" assert tidy_spacing("토목부문 › 측 량 › 기준점 측량") == "토목부문 › 측량 › 기준점 측량" assert ( tidy_spacing("그 외 자재의 운반품셈") == "그 외 자재의 운반품셈" ) # 한 글자 낱말 둘은 진짜 띄어쓰기 assert tidy_spacing("호박돌 및 야면석 · 뒷길이 25㎝") == "호박돌 및 야면석 · 뒷길이 25㎝"