Files
Aislo/resources/tester/test_work_item_key_registry.py
T
eomsangdonandClaude Opus 5 7dd2c51530 feat(b08): 공종 축에 불변 열쇠 — FW-00001 꼴 5자리 · 목차 번호는 칸으로 내림
- 열쇠 장부 `work_item_keys.json` — 판 없는 영구 장부 · 판(고시 2025-82)+목차 자리로만 이음
  · 장부에 없으면 새 열쇠 + 「새로 난 공종」 목록(짐작으로 안 이음)
- 새 칸 여섯 — work_item_key · toc_code · toc_number · toc_edition · path_name · axis_role
  `work_item_code`(FP-…) 는 이름째 그대로 둠 — 164 파일이 씀
- 잎 경로 이름 — 「토공 › 토사깍기 › 인력」 · 이름 겹침 35줄이 갈림
- 줄 구실 넷 — 공종 304 · 묶는 마디 82 · 총칙 78 · 빈 잎 13 (지우지 않고 가름만)
  ⚠ 총칙 2장은 B09 연료·손료가 읽어 못 지움(Consumables.py:44~99)
- ⚠ 목차 번호 겹침 둘 발견 — 12-17-2 · 12-24-1 이 서로 덮고 있었음 · 줄마다 제 열쇠로 갈라
  `toc_duplicate_rows` 목록으로 냄
- 이름표 — 공종 축 구획(`work_item_axis`) 새로 · 갈래 「공종」 · kind 둘 · 열 열하나
- 금액 불변 시험 14개 — 새 칸 떼면 옛 벌과 지문(sha256) 같음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
2026-09-17 11:04:04 +09:00

215 lines
9.1 KiB
Python

"""공종 축 불변 열쇠 검사 — 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,
path_names,
toc_edition,
toc_slot,
)
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
BASELINE_WORK_ITEMS_SHA = "f8b8fba1ab5e6d1d5611b99148eecbed1eaf049c42d7154820f0d9a45e20ffea"
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
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 을 두 번 적었음 — 번호를 열쇠로 못 쓰는 실물."""
dups = master["toc_duplicate_rows"]
assert [d["toc_number"] for d in dups] == ["12-17-2", "12-24-1"]
assert [d["slot"] for d in dups] == ["FP-12-17-02#2", "FP-12-24-01#2"]
for number in ("12-17-2", "12-24-1"):
rows = [it for it in master["work_items"] if it["number"] == number]
assert len(rows) == 2
assert len({r["work_item_key"] for r in rows}) == 2
assert len({r["path_name"] for r in rows}) == 2
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:
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"