Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
360 lines
17 KiB
Python
360 lines
17 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,
|
||
assign_variant_keys,
|
||
format_key,
|
||
general_provision_roots,
|
||
load_policy,
|
||
path_names,
|
||
toc_edition,
|
||
variant_display,
|
||
variant_match,
|
||
toc_slot,
|
||
)
|
||
|
||
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
|
||
|
||
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
|
||
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
|
||
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
|
||
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
|
||
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
|
||
#: 2026-09-17 랩탑 메인 둘째 — 헛단위 뺌(인용을 제목으로 읽은 13-3 표 셋 · 부록 사례 번호 겹침 일곱)으로 다시 뜸
|
||
#: (옛 be88f730…). 바뀐 줄은 13-3·4-1·4-2·2-1·2-2·3-1·3-2·3-3·13-4-1·13-5-2 의 표 귀속뿐 —
|
||
#: `test_work_item_master_toc` 가 자리를 잼 · 단가표 대조는 그 파일 머리.
|
||
BASELINE_WORK_ITEMS_SHA = "2accafe5e366c32eb6e95123642b4330e28537b2199dee851247e996fcd839da"
|
||
|
||
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
|
||
ADDED_FIELDS = (
|
||
"work_item_key",
|
||
"toc_code",
|
||
"toc_number",
|
||
"toc_edition",
|
||
"path_name",
|
||
"axis_role",
|
||
"variants",
|
||
)
|
||
|
||
|
||
@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 True # 2026-09-17 안티그래비티 조사 · 브레인 확정
|
||
# 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`).
|
||
# ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다.
|
||
assert const["general_provision_roots"] == [
|
||
"01_공통부문/제1장_적용기준.md",
|
||
"01_공통부문/제8장_건설기계.md", # 산림 FP-02(소요재료·기계손료)와 짝
|
||
]
|
||
# 유지관리 제1장 「공 통」은 총칙이 아니라 **공종** — 미확정 목록이 비었다
|
||
assert const["general_provision_pending"] == []
|
||
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
|
||
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"] == 112 # 적용기준 37 + 건설기계 75
|
||
assert sum(roles.values()) == len(doc["work_items"]) == 1367
|
||
for root, count in (("CP-01-01", 37), ("CP-01-08", 75)):
|
||
under = [
|
||
it
|
||
for it in doc["work_items"]
|
||
if it["work_item_code"] == root or it["work_item_code"].startswith(root + "-")
|
||
]
|
||
assert len(under) == count
|
||
assert {it["axis_role"] for it in under} == {"general_provision"}
|
||
# 유지관리 제1장 「공 통」은 공종으로 남는다(총칙 아님)
|
||
공통 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-05-01")
|
||
assert 공통["axis_role"] != "general_provision"
|
||
|
||
|
||
def test_갈래에도_불변_열쇠가_있다(master: dict) -> None:
|
||
"""갈래 열쇠는 `FW-00105#01` — **글자가 아니라 번호**다(2026-09-17 브레인).
|
||
|
||
글자를 열쇠에 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다. 공종 열쇠와 같은 병이다.
|
||
"""
|
||
items = master["work_items"]
|
||
keys, pairs = [], []
|
||
for it in items:
|
||
for v in it.get("variants") or []:
|
||
assert re.fullmatch(r"\d{2}", v["variant_key"]), v
|
||
keys.append(f"{it['work_item_key']}#{v['variant_key']}")
|
||
pairs.append((v["name"], v["name_clean"]))
|
||
assert len(keys) == 332 # 갈래 수 — 헛단위 13-3 갈래 3 뺌(335 → 332 · 2026-09-17 · 장부 번호는 남음)
|
||
assert len(set(keys)) == len(keys) # 겹침 0
|
||
# 한 공종 안에서 번호는 1부터 빠짐없이
|
||
for it in items:
|
||
got = [v["variant_key"] for v in it.get("variants") or []]
|
||
assert got == [f"{i + 1:02d}" for i in range(len(got))], it["work_item_key"]
|
||
# 이름 칸 둘 — 원문은 손대지 않고, 다듬은 것이 따로
|
||
raw = [it.get("variant_keys") or [] for it in items]
|
||
assert [n for it in items for n in (it.get("variant_keys") or [])] == [p[0] for p in pairs]
|
||
# 자간·물결표가 다듬어진 줄 — 14 → 70(2026-09-17 한 글자 낱말 자간 붙이기 「메 붙 임」→「메붙임」 · 열쇠는 그대로)
|
||
assert sum(1 for a, b in pairs if a != b) == 70
|
||
assert raw # 원문 목록은 그대로 남아 있다
|
||
|
||
|
||
def test_갈래_열쇠는_글자가_바뀌어도_안_흔들린다() -> None:
|
||
"""맞대는 꼴이 공백·물결표를 흡수한다 — 이름 다듬기가 들어와도 번호가 그대로다."""
|
||
reg = empty_registry()
|
||
node = {
|
||
"work_item_key": "FW-00105",
|
||
"sort_order": 1,
|
||
"variant_keys": ["1.1∼1.5", "간 단"],
|
||
}
|
||
assign_variant_keys([node], reg)
|
||
before = [v["variant_key"] for v in node["variants"]]
|
||
# 원문을 다듬어도(물결표 바꿈 · 자간 정리) 같은 번호가 나와야 한다
|
||
node2 = {"work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.1~1.5", "간 단"]}
|
||
newly = assign_variant_keys([node2], reg)
|
||
assert [v["variant_key"] for v in node2["variants"]] == before
|
||
assert newly == [] # 새 번호를 내지 않는다
|
||
assert variant_match("간 단") == variant_match("간 단") == "간단"
|
||
assert variant_display("직경 60㎝이상 ~80㎝미만") == "직경 60㎝이상 ∼80㎝미만"
|
||
|
||
|
||
def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
|
||
"""⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.1∼1.5`).
|
||
|
||
품셈 원문이 물결표를 두 가지로 쓰고 자간 공백도 들쭉날쭉이라, 문자열을 손질하면 **열쇠가 갈린다.**
|
||
지금 몇 개가 그런지 못박아 둔다 — 늘면 손질이 들어간 것이고, 줄면 열쇠가 바뀐 것이다.
|
||
"""
|
||
import collections
|
||
|
||
keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])]
|
||
assert len(keys) == 332 # 2026-09-17 헛단위 13-3 갈래 3 뺌(335 → 332)
|
||
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
|
||
assert tilde == {"∼": 26, "~": 8}, "물결표 쓰임이 바뀜 — 열쇠가 갈렸는지 볼 것"
|
||
squeezed = collections.defaultdict(set)
|
||
for k in keys:
|
||
squeezed["".join(k.split()).replace("~", "∼")].add(k)
|
||
messy = {n: v for n, v in squeezed.items() if len(v) > 1}
|
||
assert len(messy) == 6, f"공백·물결표만 다른 짝이 {len(messy)} — 6 이던 것"
|
||
|
||
|
||
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"
|