"""공종 축 불변 열쇠 검사 — 2026-09-17 브레인 ①. 여기서 못박는 것은 하나다. **금액이 움직이면 안 된다.** 열쇠를 얹는 일은 값을 바꾸는 일이 아니므로, 새 칸을 떼어내면 옛 벌과 **한 글자도 달라선 안 된다.** 그 자리를 지키는 것이 `test_옛_값이_한_글자도_안_바뀜` 의 지문(sha256)이다. 나머지는 열쇠의 약속 — 안 겹침·꼴·두 번 지어도 같음 · **짐작으로 안 이음** · 계층(`parent_mode`)이 그대로 있음 · 총칙 2장을 지우지 않았음(B09 연료·손료가 읽는다). """ from __future__ import annotations import copy import hashlib import json import re import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Build_WorkItemMaster import OUT_DIR # noqa: E402 from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402 assign_keys, axis_role, empty_registry, format_key, general_provision_roots, load_policy, path_names, toc_edition, toc_slot, ) MASTER = OUT_DIR / "work_item_master_2026-01-01.json" #: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값. #: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것. #: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸 #: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 · #: 미판정·밑수 목록 파일은 한 글자도 안 바뀜. BASELINE_WORK_ITEMS_SHA = "be88f730b325c290012e5a2a79ad593ce5a74ffd82464226857191fefe6647e3" #: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다. ADDED_FIELDS = ( "work_item_key", "toc_code", "toc_number", "toc_edition", "path_name", "axis_role", ) @pytest.fixture(scope="module") def master() -> dict: return json.loads(MASTER.read_text(encoding="utf-8")) def _fingerprint(items: list[dict]) -> str: stripped = [{k: v for k, v in it.items() if k not in ADDED_FIELDS} for it in items] blob = json.dumps(stripped, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(blob.encode()).hexdigest() def test_옛_값이_한_글자도_안_바뀜(master: dict) -> None: """금액을 낳는 값(표·밑수·형태)이 그대로인가 — 새 칸만 떼고 지문을 맞댄다.""" assert _fingerprint(master["work_items"]) == BASELINE_WORK_ITEMS_SHA def test_모든_줄에_열쇠가_있고_안_겹침(master: dict) -> None: items = master["work_items"] keys = [it["work_item_key"] for it in items] assert all(re.fullmatch(r"FW-\d{5}", k) for k in keys) assert len(set(keys)) == len(items) def test_목차_번호는_칸으로만_남음(master: dict) -> None: """`FP-…` 는 지우지 않고 「그 판의 목차 번호」 칸으로 남았는가 — 164 파일이 아직 쓴다.""" assert master["policy"]["row_key"] == "work_item_key" assert master["policy"]["toc_is_column_not_key"] is True assert master["toc_edition"] == "산림청고시제2025-82호" for it in master["work_items"]: assert it["toc_code"] == it["work_item_code"] assert it["toc_number"] == it["number"] assert it["toc_edition"] == master["toc_edition"] def test_장부가_있으면_같은_열쇠가_다시_나옴(master: dict) -> None: """두 번 지어도 같은가 — 장부를 그대로 두고 다시 발급해 본다.""" registry = json.loads((OUT_DIR / "work_item_keys.json").read_text(encoding="utf-8")) before = copy.deepcopy(registry) nodes = [ { "work_item_code": it["work_item_code"], "number": it["number"], "name": it["name"], "sort_order": it["sort_order"], } for it in master["work_items"] ] newly, _ = assign_keys(nodes, registry, edition=master["toc_edition"]) assert newly == [] # 다시 발급하지 않는다 assert registry["next_serial"] == before["next_serial"] assert [n["work_item_key"] for n in nodes] == [ it["work_item_key"] for it in master["work_items"] ] def test_장부에_없는_코드는_새_열쇠를_받음() -> None: """⚠ 짐작으로 안 이음 — 이름이 같아도 옛 열쇠를 물려주지 않고 새로 내고 목록에 싣는다.""" registry = empty_registry() old = [{"work_item_code": "FP-09-03", "number": "9-3", "name": "토사깍기", "sort_order": 256}] assign_keys(old, registry, edition="산림청고시제2025-82호") # 새 판에서 번호가 밀린 같은 이름의 절 new = [{"work_item_code": "FP-09-04", "number": "9-4", "name": "토사깍기", "sort_order": 256}] newly, _ = assign_keys(new, registry, edition="산림청고시제9999-99호") assert new[0]["work_item_key"] != old[0]["work_item_key"] assert [n["work_item_key"] for n in newly] == [new[0]["work_item_key"]] def test_열쇠_차례는_목차_차례() -> None: registry = empty_registry() nodes = [ {"work_item_code": "FP-02", "number": "2", "name": "둘", "sort_order": 512}, {"work_item_code": "FP-01", "number": "1", "name": "하나", "sort_order": 256}, ] assign_keys(nodes, registry, edition="e") assert nodes[1]["work_item_key"] == format_key(1) assert nodes[0]["work_item_key"] == format_key(2) def test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로(master: dict) -> None: """⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 본문은 12-17-3 무근진동기 제외 · 12-27-1 지수판 설치 (2026-09-17 코덱스 원문 대조). 목차를 믿으면 뒤 줄이 앞 표를 덮어써 혼성 줄이 서고 F0364·F0375 가 사라짐. 열쇠는 같은 공종이라 그대로(장부에 손으로 이음 · `relinked`).""" assert master["toc_duplicate_rows"] == [] assert [(c["toc_number"], c["body_number"]) for c in master["toc_corrections"]] == [ ("12-17-2", "12-17-3"), ("12-24-1", "12-27-1"), ] by_key = {it["work_item_key"]: it for it in master["work_items"]} for key, number, name, tables in ( ("FW-00387", "12-17-2", "철근, 펌프카 0-15m", ["F0363"]), ("FW-00388", "12-17-3", "무근진동기 제외", ["F0364"]), ("FW-00396", "12-24-1", "뒷채움", ["F0371"]), ("FW-00401", "12-27-1", "지수판 설치", ["F0375"]), ): it = by_key[key] assert (it["number"], it["name"], [t["pum_table_id"] for t in it["tables"]]) == ( number, name, tables, ) assert not {o["pum_table_id"] for o in master["orphan_tables"]} & {"F0364", "F0375"} def test_슬롯_이름() -> None: assert toc_slot("FP-12-17-02", 1) == "FP-12-17-02" assert toc_slot("FP-12-17-02", 2) == "FP-12-17-02#2" def test_잎_경로_이름이_안_겹침(master: dict) -> None: """「인력」 3곳 · 「수확」 6곳처럼 겹치던 잎 이름이 경로 이름으로 갈리는가.""" items = master["work_items"] has_kid = {it.get("parent_code") for it in items} leaves = [it for it in items if it["work_item_code"] not in has_kid and it["tables"]] names = [it["name"] for it in leaves] paths = [it["path_name"] for it in leaves] assert len(set(names)) < len(names) # 이름만으로는 겹친다 — 경로가 필요한 까닭 assert len(set(paths)) == len(paths) 사람 = next(it for it in items if it["work_item_code"] == "FP-09-03-01") assert 사람["path_name"] == "토공 › 토사깍기 › 인력" def test_계층과_계산_규칙은_그대로(master: dict) -> None: """`parent_mode` 가 계산 규칙을 지닌다 — 묶는 마디를 지우지 않았는가.""" items = master["work_items"] modes = [it.get("parent_mode") for it in items] assert modes.count("choose_one") == 91 assert modes.count("sum_steps") == 3 발파암 = next(it for it in items if it["work_item_code"] == "FP-09-05") assert [s["code"] for s in 발파암["steps"]] == ["FP-09-05-01", "FP-09-05-02", "FP-09-05-03"] def test_총칙은_지우지_않고_구실로_가름(master: dict) -> None: """⚠ `B09_Estimation_Consumables` 가 총칙 2장 표를 읽는다 — 지우면 연료·손료가 사라진다.""" by_code = {it["work_item_code"]: it for it in master["work_items"]} for code, table_id in ( ("FP-02-01-01", "F0042"), # 체인톱 보통휘발유 ("FP-02-02-01", "F0064"), # 체인톱 손료계수 ("FP-02-02-06", "F0069"), # 양수기 손료계수 ): node = by_code[code] assert node["axis_role"] == "general_provision" assert table_id in [t["pum_table_id"] for t in node["tables"]] roles = master["stats"]["axis_roles"] assert sum(roles.values()) == len(master["work_items"]) assert roles["general_provision"] == 78 def test_구실_가름_규칙() -> None: assert ( axis_role({"work_item_code": "FP-01-02", "tables": []}, has_children=True) == "general_provision" ) assert ( axis_role({"work_item_code": "FP-09-03-01", "tables": [{}]}, has_children=False) == "work_item" ) assert axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True) == "group" assert axis_role({"work_item_code": "FP-09-01", "tables": []}, has_children=False) == "empty" def test_총칙_범위는_코드가_아니라_자료가_정함() -> None: """⚠ 뿌리를 코드에 박으면 관리자가 못 고친다 — `axis_policy.json` 이 정본.""" assert general_provision_roots("forest") == ("FP-01", "FP-02") const = load_policy("const") assert const["key_prefix"] == "CW" assert const["settled"] is False # 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`). # ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다. assert const["general_provision_roots"] == ["01_공통부문/제1장_적용기준.md"] assert [p["code"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"] # 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거 assert ( axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",)) == "general_provision" ) def test_건설_총칙_뿌리가_실제로_붙음() -> None: """잣대가 건설 산출에 닿았는가 — 뿌리 아래 37줄이 `general_provision` 으로 섰다.""" doc = json.loads( (OUT_DIR / "const_work_item_master_2026-01-01.json").read_text(encoding="utf-8") ) roles = doc["stats"]["axis_roles"] assert roles["general_provision"] == 37 assert sum(roles.values()) == len(doc["work_items"]) == 1367 under = [ it for it in doc["work_items"] if it["work_item_code"] == "CP-01-01" or it["work_item_code"].startswith("CP-01-01-") ] assert len(under) == 37 assert {it["axis_role"] for it in under} == {"general_provision"} # ⬜ 미확정 둘은 아직 공종·묶음으로 서 있다 — 안티그래비티 답이 오면 뿌리에 더한다 기계 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-01-08") assert 기계["axis_role"] != "general_provision" def test_어느_구실도_지우지_않음() -> None: """브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」.""" import json as _json from pathlib import Path as _Path policy = _json.loads( _Path("resources/data_work_item_master/axis_policy.json").read_text(encoding="utf-8") ) assert policy["policy"]["never_delete"] is True assert policy["policy"]["flat_list_filter"] == "pum_tables > 0" assert all(role["keep"] for role in policy["roles"].values()) assert list(policy["roles"]) == ["work_item", "group", "general_provision", "empty"] def test_경로_이름_잇기() -> None: nodes = [ {"work_item_code": "A", "parent_code": None, "name": "토공"}, {"work_item_code": "A-1", "parent_code": "A", "name": "토사깍기"}, {"work_item_code": "A-1-1", "parent_code": "A-1", "name": "인력"}, ] assert path_names(nodes)[2] == "토공 › 토사깍기 › 인력" def test_고시_번호를_원문_경로에서_읽음() -> None: sources = [ {"path": "resources/knowledge/original/…/(산림청고시 제2025-82호) 산림사업 표준품셈.md"} ] assert toc_edition(sources, "2026-01-01") == "산림청고시제2025-82호" assert toc_edition([], "2026-01-01") == "2026-01-01"