"""건설 공종 축(`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 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"] assert stats["tables_total"] == 2192 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"))