Files
Aislo/resources/tester/test_work_item_key_registry.py
T

306 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""공종 축 불변 열쇠 검사 — 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_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
#: 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",
)
@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_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
"""⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.11.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"