Files
Aislo/resources/tester/test_work_item_key_registry.py
T
eomsangdonandClaude Opus 5 5cdf305619 feat(b08): 확인 대기 75건을 셋으로 가름(㉮41·㉯23·㉰11) · 「당」 없는 몫 꼴을 읽어 밑수 251
**① 확인 대기 가르기** — 「다 사유 있음」으로 뭉쳐 있던 것을 갈래로 나눠 **무엇이 진짜 할 일인지** 보이게 함
  ㉮ **영영 할 일 아님 41** — 「일당」은 품의 단위 · 작업조 표(넣으면 호표가 사라짐) · 「인/조」 ·
    사람 판정으로 닫힌 표 성격 7 · 비목 구성표 7 따위. **집계에서 갈라 둠**(다음 바퀴에 또 안 세어짐)
  ㉯ **남이 고쳐야 풀림 23** — [주] 17(코덱스 md 읽기 넓히기) · 밑수 5(마스터가 맞고 md 가 덜 읽음) ·
    F0333 원문 md 의심(코덱스 PDF 대조)
  ㉰ **사용자 판단 필요 11** — ⚠ 아래 셋이 제근 9-21 같은 자리
    · F0046·F0068 마스터 밑수가 「1인」 — 「인」은 밑수가 아니라는 2026-09-09 판정과 어긋남
    · F0076 「1km」는 반쪽 — 원문은 「인/1일 1km당」으로 분모가 둘
    · 그 밖 부록 표 셋(떼어 낼지) · F0405 증가율표 성격 · 토량환산계수 2 · 이형철근 범위 · rebar 1

**② 「당」 없이 몫으로만 적힌 자리를 읽게 함** — 「(인/100㎡)」·「(인/ha)」·「(인/㎡)」·「(대/ton)」.
  ⚠ 이 구멍으로 다섯 표가 「원문 부재」로 **잘못 세어지고 있었음**(실은 원문이 적어 둔 것).
  가림을 넓히고 그 다섯을 사람 판정으로 채움 ⇒ 밑수 확보 246 → **251** · 미확보 84 → 79
  (원문 부재 69 + 미판독 10)

시험 — 장부 비교에서 **사람이 적은 메모 칸**(`why`·`class`·`class_why`)을 빼도록 함(자료가 아니라 설명)

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

586 lines
31 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,
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 두 번 갱신 — 둘 다 **더하기만**(덮어쓴 것 0 · 사라진 것 0):
#: ① 표머리 몫꼴(`인/ha`·`인/100kg`)에서 밑수 18개를 새로 읽음
#: ② 형태 미판정 14표를 사람 판정으로 가름(안티그래비티 원문 대조) · 그중 5표는 밑수도 확인
#: ③ 원문 전수 대조 60건 중 **원문 괄호가 분명한 16표**의 밑수를 채움
#: ⚠ 그 가운데 **하나(F0430)만 값이 바뀜** — 원문이 「1일당」인데 「개」로 서 있던 왜곡을 고침
#: ④ 표 아래 `[주]` 글을 `notes` 칸으로 실음(순수 더하기 — 원문 셀·밑수·형태 다 그대로)
#: ⑤ 그 `[주]` 추출의 오염을 걷어냄 — 원문이 절 제목을 목록 줄로 적어 제목·다음 절 [주]까지
#: 끌어오고 있었음(산림 표 320 → 306 · 건설 1,761 → 1,106)
#: ⑥ ⚠ 그 걷어내기가 **진짜 계수 34건까지 잘랐던 것**을 되살림(고철 공제·위험목 100% 가산 …)
#: ⑦ 실값인데 「참고」로 빠져 있던 13표를 `sub_requirement` 로 — 값은 실값, 자리는 상위 공종 종속
#: ⑨ 원천 벌을 다시 뽑아 지음 — 밑수 238 → 242 · ⚠ 5-26-1·2·3 밑수를 사람 판정 100㎡ → 원문 992㎡ 로 물림
#: ⑧ 못 읽던 [주] 세 꼴을 읽게 함 — 별표 줄 · 인용 산출예시 뒤 줄 · **긴 표의 [주]**(줄 수 제한 없앰)
#: ⇒ 곱하면 안 되던 줄이 곱할 수 있게 된 것이지, 있던 값이 바뀐 것이 아니다.
#: 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 = "803898a371e82c8a66692807af189a646c9b6fb3f077a4054cf6ef6030d8341b"
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
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 # 갈래 수
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]
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.11.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.11.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.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_산림_형태_미판정이_없다(master: dict) -> None:
"""2026-09-17 안티그래비티 원문 대조로 마지막 14표를 가름 — 미판정이 0 이 됐다.
⚠ 형태가 뒤집히면 값이 20배쯤 조용히 틀린다. 미판정으로 남겨 두는 편이 안전했으나,
14표 모두 **금액 대상**이라 비워 두면 그 공종이 통째로 안 선다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import BASIS_JUDGMENTS, judged_form
forms = [t["pum_form"] for it in master["work_items"] for t in it["tables"]]
assert "undetermined" not in forms
assert master["stats"]["form_undetermined"] == 0
# 열넷이 실제로 사람 판정으로 섰는가
가른것 = {
"F0140": "requirement", "F0141": "requirement", "F0142": "requirement",
"F0195": "productivity", "F0248": "coefficient", "F0322": "requirement",
"F0323": "requirement", "F0333": "requirement", "F0346": "requirement",
"F0169": "productivity", "F0304": "requirement", "F0356": "productivity",
"F0257": "coefficient", "F0349": "requirement",
} # fmt: skip
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
for table_id, form in 가른것.items():
assert by_id[table_id]["pum_form"] == form, table_id
assert by_id[table_id]["form_basis"].startswith("사람 판정"), table_id
# 미판정 열넷 가운데 머리가 소실돼 자동으로는 못 읽는 표의 밑수를 손으로 적었다
# (그 뒤 원문 전수 대조로 다른 표들도 들어와 `BASIS_JUDGMENTS` 는 더 넓다)
assert {"F0140", "F0322", "F0333", "F0346"} <= set(BASIS_JUDGMENTS)
assert by_id["F0140"]["basis_quantity"] == 992.0 and by_id["F0140"]["basis_unit"] == "㎡"
assert by_id["F0322"]["basis_quantity"] == 1000.0 and by_id["F0322"]["basis_unit"] == "본"
assert judged_form("F0140", "5-26-1")[0] == "requirement"
def test_표_아래_주석이_실린다(master: dict) -> None:
"""`[주]` 에 **금액에 바로 닿는 조건**이 적혀 있는데 마스터가 그 글을 안 싣고 있었다.
발파 가산 20% · 고철 공제 50kg/㎥ · 공구손료 3% · 고소 감속 9% — 받는 쪽이 볼 길이 없었다
(2026-09-17 안티그래비티 전수 대조 ④). ⚠ **읽기만 싣는다 — 여기서 셈하지 않는다.**
"""
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
쓰인것 = [t for t in by_id.values() if t.get("notes")]
assert len(쓰인것) == 309
발파 = " ".join(by_id["F0245"]["notes"])
assert "인건비의 20%를 가산" in 발파
유로폼 = " ".join(by_id["F0395"]["notes"])
assert "인력품의 3%" in 유로폼
# ⚠ **남의 글을 물어 오지 않는다**(2026-09-17 랩탑 서브 전수 확인으로 드러난 오염을 고침)
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import (
is_bare_title,
trim_page_break,
)
= [n for t in 쓰인것 for n in t["notes"]]
assert all(not n.startswith("#") for n in )
# 원문이 절 제목을 목록 줄로 적는다 — 「- 3. 노상 및 노반재료」·「- 라. 체적환산계수(f)표」
assert not [n for n in if is_bare_title(n)]
assert is_bare_title("- 3. 노상 및 노반재료")
assert not is_bare_title("- ② 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.")
# 쪽이 넘어가며 줄 **끝에** 눌어붙은 쪽번호·장 제목도 뗀다
assert trim_page_break("- ⑥ 적재운반 적하는 1인을 기준으로 한다.15제1장 적용기준") == (
"- ⑥ 적재운반 적하는 1인을 기준으로 한다."
)
# 제 [주] 가 없는 표가 제목 건너 남의 [주] 를 빌려오지 않는다(F0009 는 강재류 것을 빌려왔었다)
by_id2 = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
assert not by_id2["F0009"].get("notes")
def test_잘려_나갔던_진짜_계수가_살아_있다(master: dict) -> None:
"""⚠ [주] 오염을 걷다가 **있어야 할 계수까지 잘렸던** 자리(2026-09-18 2바퀴 검사 34건).
까닭 둘 — 「> 【산출 예시】」 인용이 [주] 중간에 끼면 거기서 끊었고,
「- 4. 고철공제 : A=…」처럼 **번호를 달고 이어지는 진짜 규칙**을 절 제목으로 보고 잘랐다.
⇒ 잣대는 **지우는 것보다 남기는 것** — 알맹이(콜론·숫자) 없는 짧은 줄만 제목으로 본다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import is_bare_title
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
for table_id, 낱말 in (
("F0252", "고철공제"), # 50kg/㎥ 공제
("F0088", "산림병해충"), # 인력 제거 시 보통인부 100% 가산
("F0086", "관목류"), # 보통인부 30% 할증
("F0191", "잔존목"), # 주행·잔존목 할증
):
assert 낱말 in " ".join(by_id[table_id]["notes"]), table_id
# 알맹이 없는 제목만 자른다 — 규칙을 담은 번호 줄은 남긴다
assert is_bare_title("- 3. 노상 및 노반재료")
assert is_bare_title("- 라. 체적환산계수(f)표")
assert not is_bare_title("- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80%")
assert not is_bare_title("- 2. 철근절단 : 철근콘크리트(T=30㎝ 미만)품을 적용한다.")
def test_실값인데_참고로_빠져_있던_표(master: dict) -> None:
"""⚠ `requirement` 로 올리지 않는다 — 상위 공종이 이미 세는 몫을 두 번 세게 된다.
`reference` 로 두면 금액이 0원으로 증발하고, `requirement` 면 이중 계상된다.
그래서 `sub_requirement`(값은 실값 · 자리는 상위 공종 종속)로 둔다(2026-09-18 안티그래비티 권고).
"""
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
실값 = [k for k, t in by_id.items() if t["pum_form"] == "sub_requirement"]
assert len(실값) == 13
assert {"F0369", "F0386", "F0393", "F0408", "F0410"} <= set(실값)
# 설계 치수·조건 분류표 일곱은 그대로 참고다
for table_id in ("F0396", "F0400", "F0406", "F0411", "F0412", "F0413", "F0446"):
assert by_id[table_id]["pum_form"] == "reference", table_id
def test_사람_판정은_원문이_나오면_물러난다(master: dict) -> None:
"""⚠ 5-26-1·2·3 — 앞서 100㎡ 로 메워 둔 밑수를 **원문 「(인/992㎡당)」** 으로 물렸다.
머리가 소실돼 원문을 못 읽던 때 흔한 밑수로 메운 값이었다. 이 고리의 잣대는 「원문이 정본」이므로
사람 판정이 물러난다(2026-09-18 브레인 · 코덱스가 PDF 에서 되살린 줄).
⚠ 100 과 992 는 열 배 가까이 달라 **금액이 크게 갈리는 자리**다 — 되돌릴 때도 근거를 보고 할 것.
"""
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
for table_id in ("F0140", "F0141", "F0142"):
assert by_id[table_id]["basis_quantity"] == 992.0, table_id
assert by_id[table_id]["basis_unit"] == "㎡", table_id
assert "992" in by_id[table_id]["basis_source"], table_id
def test_밑수_미확보를_둘로_가른다(master: dict) -> None:
"""⚠ 「원문 부재」는 **채울 것이 아니다** — 없는 값을 지어내면 안 된다.
미확보 87 중 **75 는 원문에 밑수가 아예 없고**, **12 만 원문이 적었는데 우리가 못 읽은 것**이다
(2026-09-18 안티그래비티 3바퀴 · 브레인). 목록에서 갈라 두면 다음 바퀴에 75 가 또
「할 일」로 세어지지 않는다.
"""
import json as _json
from pathlib import Path as _Path
stats = master["stats"]
assert stats["basis_missing"] == 79
assert stats["basis_absent_in_source"] == 69
# 2026-09-18 「당」 없이 몫으로만 적힌 자리(「(인/100㎡)」·「(대/ton)」)를 가리게 해 다섯을 더 채움.
# 미판독 12 → 9 → 10(가림이 넓어져 새로 드러난 것 포함) · 확보 243 → 251.
# ⚠ 남은 아홉은 **거절 자리**다 — 「(단위 : 인)」 셋(2026-09-09 오독 판정) ·
# 「㎥/1인/1일」·「1ha당, 100본당」·「1대 1조, 시간당」·「인 당」은 분모가 둘 이상이다.
assert stats["basis_unparsed"] == 10
assert stats["basis_absent_in_source"] + stats["basis_unparsed"] == stats["basis_missing"]
items = _json.loads(
_Path("resources/data_work_item_master/basis_missing_2026-01-01.json").read_text(
encoding="utf-8"
)
)["items"]
assert all(i["why"] for i in items) # 사유 없는 줄이 없어야 한다
# 「공㎥」 처럼 단위 목록에 없던 자리는 사람 판정으로 채웠다 — 목록을 넓히면 규격이 밑수가 된다
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
assert by_id["F0366"]["basis_unit"] == "공㎥"
for table_id in ("F0050", "F0052", "F0224", "F0090"):
assert by_id[table_id]["basis_quantity"] == 1.0 and by_id[table_id]["basis_unit"] == "ha"
# 「당」 글자 없이 몫으로만 적힌 자리도 읽는다 — 「(인/100㎡)」·「(대/ton)」
assert by_id["F0089"]["basis_quantity"] == 100.0 and by_id["F0089"]["basis_unit"] == "㎡"
assert by_id["F0191"]["basis_unit"] == "톤"
def test_원문_대조_밑수_판정(master: dict) -> None:
"""원문 md ↔ 마스터 전수 대조(안티그래비티 02544795)에서 나온 밑수를 반영했는가.
⚠ **보고가 적은 단위를 그대로 믿지 않는다.** 보고는 「인」을 밑수로 적은 자리가 셋 있었고
그 자리(8-6-2 드론방제·8-6-3 지상방제)는 2026-09-09 에 오독으로 판정된 바로 그 표다.
원문 줄을 열어 괄호 글을 확인한 것만, 그것도 **분모가 하나일 때만** 싣는다.
"""
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
for table_id, quantity, unit in (
("F0082", 1.0, "ha"), # (단위 : 인/ha)
("F0311", 1.0, "기"), # (단위: 1기당)
("F0424", 1.0, "㎡"), # (단위: 인/㎡)
("F0430", 1.0, "일"), # ⚠ 왜곡 고침 — 「개」로 서 있었음
):
assert by_id[table_id]["basis_quantity"] == quantity, table_id
assert by_id[table_id]["basis_unit"] == unit, table_id
# 「인」은 밑수가 아니다 — 보고가 그렇게 적었어도 안 넣는다
for table_id in ("F0228", "F0229", "F0230"):
assert by_id[table_id]["basis_unit"] != "인", table_id
# 분모가 둘인 자리도 안 넣는다 — 3-2 작업로 선정은 원문이 「인/1일 1km당」이다
assert by_id["F0076"]["basis_unit"] != "ha"
# ⚠ **작업조 표는 밑수를 비워 둔다** — 원문이 「(단위: 일 당)」이라도 넣지 않는다.
# 밑수가 시공량 열이라 「1일」을 박으면 그 길이 막혀 호표가 통째로 사라진다
# (2026-09-17 유로폼 12-38-3 에서 실제로 사라져 드러남).
for table_id in ("F0339", "F0340", "F0341", "F0395", "F0449"):
assert by_id[table_id]["crew_table"] is True, table_id
assert by_id[table_id]["basis_unit"] is None, table_id
def test_표머리_몫꼴_밑수_읽기() -> None:
"""「당」 없이 몫으로만 적힌 표머리에서 밑수를 읽는다 — 분모가 밑수다(명세 꼴 B).
⚠ 반쪽만 읽느니 「미확보」가 낫다 — 분모가 둘이거나(`ℓ/일,대`) 표머리끼리 어긋나면 거절한다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import basis_from_header, detect_basis
assert basis_from_header("(단위 : 인/ha)") == (1.0, "ha")
assert basis_from_header("소요인력 (인/100kg)") == (100.0, "kg")
assert basis_from_header("소요인력(인/본당)") == (1.0, "본")
assert basis_from_header("재료비(1km 소요량기준)") == (1.0, "km")
assert basis_from_header("주연료 (/일,대)") == (None, None) # 분모가 둘 — 거절
assert basis_from_header("기계명(주재료)") == (None, None) # 몫이 아님
# 표머리끼리 어긋나면 표 전체를 미확보로 둔다(건설 8장 기계경비표)
= {"headers": ["분류번호", "주연료 (/hr)", "조종원 (인/일)"], "rows": []}
assert detect_basis() == (None, None)
# 하나로 모이면 읽는다
표2 = {"headers": ["구 분", "정리산물(㎥/ha)", "비고"], "rows": []}
assert detect_basis(표2) == (1.0, "ha")
def test_밑수_채움은_더하기만_함(master: dict) -> None:
"""⚠ 이미 있던 밑수를 덮지 않는다 — 덮으면 금액이 조용히 움직인다.
표 안 칸(`BASIS_RE`)이 먼저고, 표머리 몫꼴은 **그 뒤**에만 본다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import detect_basis
= {"headers": ["(단위 : 인/ha)", "100㎡당"], "rows": []}
assert detect_basis() == (100.0, "㎡") # 「당」이 붙은 쪽이 이긴다
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"