Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
152 lines
6.9 KiB
Python
152 lines
6.9 KiB
Python
"""건설 공종 축(`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"))
|