Files
Aislo/resources/tester/test_b08_work_item_master.py
eomsangdonandClaude Opus 5 d7cb14f416 test(B08): tmp/tests 중 tester 에 없던 47 개를 resources/tester 로 옮김
tmp/ 가 창끼리 안 건너가는 것이 확정돼(랩탑이 시간 두고 두 번 확인) 시험·예외가
저절로 건너가도록 git 안으로 옮김. 사용자 확정.

- 내용은 하나도 안 고침 — 자리만 옮김. tmp/tests 는 남겨 둠.
- 같은 이름이 이미 있던 64 개는 랩탑 것을 그대로 두고 건너뜀.
- helper_b05_*.js 둘은 랩탑이 .cjs 로 이미 올린 것과 **줄바꿈만 다른 같은 내용**이라
  복사본을 도로 뺌(시험이 .cjs 를 부름).
- resources/tester/ 에서 전체 1176 통과 · 29 건너뜀 · 실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:15:57 +09:00

753 lines
33 KiB
Python
Raw Permalink 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.
"""B08 공종 마스터 정규화 검사 — PLAN 8-6·8-7.
제일 중요한 것은 `pum_form` 이다. 생산량형(1÷값)과 소요량형(값÷밑수)은 환산 방향이
반대라, 뒤집히면 값이 오류 없이 조용히 20배쯤 틀린다. 그래서 실물 표를 집어
방향이 맞는지 못 박아 둔다.
"""
from __future__ import annotations
import json
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 ( # noqa: E402
OUT_DIR,
detect_form,
parse_toc,
section_number,
)
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
UNDET = OUT_DIR / "form_undetermined_2026-01-01.json"
@pytest.fixture(scope="module")
def master() -> dict:
if not MASTER.exists():
pytest.skip("공종 마스터 미생성 — 빌드 스크립트를 먼저 실행할 것")
return json.loads(MASTER.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def tables() -> list[dict]:
src = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
return json.loads(src.read_text(encoding="utf-8"))["variables"]["pum"]["tables"]
def find(tables: list[dict], table_id: str) -> dict:
return next(t for t in tables if t["table_id"] == table_id)
# ── pum_form — 뒤집히면 값이 반대가 되는 자리 ──────────────────────────
def test_생산량형_작업능력표(tables: list[dict]) -> None:
"""`9-4-1 암파쇄` 는 「㎥/hr」 작업능력표다 — 품 = 1 ÷ 값."""
form, why = detect_form(find(tables, "F0241"), "9")
assert form == "productivity", why
def test_생산량형이_비고의_직종에_뒤집히지_않을것(tables: list[dict]) -> None:
"""`9-7-1` 은 「작업능력」 표인데 비고에 「보통인부 1인/일」이 붙어 있다.
직종을 먼저 보면 소요량형으로 뒤집힌다 — 그 사고를 막는 순서를 고정한다.
"""
table = find(tables, "F0247")
assert "보통인부" in json.dumps(table["rows"], ensure_ascii=False)
form, why = detect_form(table, "9")
assert form == "productivity", why
def test_소요량형_직종표(tables: list[dict]) -> None:
"""`9-3-1 인력` 은 「보통인부(인) 0.16」 — ㎥당 품이다."""
form, why = detect_form(find(tables, "F0239"), "9")
assert form == "requirement", why
def test_소요량형_직종이_둘째열에_있어도_잡을것(tables: list[dict]) -> None:
"""`9-12-1 토사` 는 첫 열이 「인력(10%)」, 직종은 둘째 열에 있다."""
form, why = detect_form(find(tables, "F0258"), "9")
assert form == "requirement", why
def test_계수표는_공종이_아님(tables: list[dict]) -> None:
"""`9-3-2 기계` 는 시공능력 공식의 K·f·E 파라미터다."""
form, why = detect_form(find(tables, "F0240"), "9")
assert form == "coefficient", why
def test_1장은_기준표(tables: list[dict]) -> None:
"""품셈 제1장은 적용기준이라 공종으로 세우지 않는다."""
form, why = detect_form(find(tables, "F0008"), "1")
assert form == "reference", why
def test_참조지시는_기준표(tables: list[dict]) -> None:
"""`10-3-1 모래` 는 값이 아니라 「별도계상」 이라는 지시뿐이다."""
form, why = detect_form(find(tables, "F0298"), "10")
assert form == "reference", why
# ── 계층·코드 ────────────────────────────────────────────────────────
def test_절번호_파싱() -> None:
assert section_number("9-3-1. 인력") == "9-3-1"
assert section_number("12-2 표면 마무리를 따른다.") == "12-2"
assert section_number("부록1") is None
def test_목차_계층() -> None:
rows = [["제9장", "토공", "50"], ["9-3", "흙깎기", "51"], ["9-3-1", "인력", "51"]]
nodes = parse_toc(rows)
assert [n["work_item_code"] for n in nodes] == ["FP-09", "FP-09-03", "FP-09-03-01"]
assert [n["level"] for n in nodes] == [1, 2, 3]
assert nodes[2]["parent_code"] == "FP-09-03"
# STmate 관례대로 256 간격 — 중간 삽입 여유를 둔다.
assert [n["sort_order"] for n in nodes] == [256, 512, 768]
# ── 산출물 계약 (B09 가 읽는 부분) ─────────────────────────────────────
def test_원문셀을_버리지_않을것(master: dict) -> None:
"""`raw_row` 가 없으면 B09 가 476표를 다시 열어야 한다 — PLAN 8-7."""
got = [t for n in master["work_items"] for t in n["tables"]]
assert got, "표가 하나도 안 붙었다"
assert all("raw_row" in t for t in got)
assert any(t["raw_row"] for t in got)
def test_자원축은_비워둘것(master: dict) -> None:
"""자원 축(`resource_kind` 등)은 B09 몫이다 — 여기서 채우면 경계 위반."""
forbidden = {"resource_kind", "resource_code", "resource_spec", "amount", "amount_unit"}
for node in master["work_items"]:
for table in node["tables"]:
assert not (forbidden & set(table)), f"{table['pum_table_id']} 에 자원 축이 섞였다"
def test_dataset_version_세쪽_기록(master: dict) -> None:
"""파일명만으로는 같은 날짜 재생성본과 구분이 안 된다 — PLAN 8-6."""
dv = master["dataset_version"]
assert dv["dataset_id"] and dv["effective_date"]
assert len(dv["sha256"]) == 64
def test_미판정은_빈칸이_아니라_목록으로(master: dict) -> None:
"""판정 못 한 표를 조용히 비워 두면 뒤집힌 값이 들어간다 — 반드시 목록으로 낸다."""
if not UNDET.exists():
pytest.skip("미판정 목록 미생성")
listed = {i["pum_table_id"] for i in json.loads(UNDET.read_text(encoding="utf-8"))["items"]}
flagged = {
t["pum_table_id"]
for n in master["work_items"]
for t in n["tables"]
if t["pum_form"] == "undetermined"
}
assert flagged <= listed, "마스터에서 미판정인데 목록에 없는 표가 있다"
def test_귀속률(master: dict) -> None:
"""표가 목차 계층에 붙어야 공종으로 선다. 미귀속이 늘면 목차 파싱이 깨진 것."""
s = master["stats"]
assert s["tables_attached"] / s["tables_total"] > 0.9
# ── 첫 칸이 분류 딱지인 표 (2026-09-07 교차 확인에서 나옴) ──────────
#
# 서브 창이 자기 파싱에서 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표를 놓쳐 자재·장비가
# 통째로 빠지던 것을 잡았고, 같은 병이 여기 형태 판정에도 있었다 — 12장 임도 구조물 표
# 8건이 `undetermined` 로 빠져 있었다. 다시 걸리지 않게 못 박는다.
def test_딱지형_표가_소요량형으로_판정될것() -> None:
"""`['자재', 'PVC 지수판', 'm', '1.04']` — 첫 칸이 이름이 아니라 갈래다."""
table = {
"headers": ["구 분", "단위", "적 용", "비 고", ""],
"rows": [
["자재", "PVC 지수판(200×5)", "m", "1.04", "4% 할증"],
["용접봉", "kg", "0.042", "", ""],
["철선(#8)", "kg", "0.21", "", ""],
["인력(설치비)", "특별인부", "인", "0.151", ""],
],
}
form, basis = detect_form(table, "12")
assert form == "requirement"
assert "딱지" in basis
def test_직종이_넷째_행에_있어도_잡힐것() -> None:
"""첫 세 행만 보던 탓에 놓쳤던 모양 — 강관동바리의 형틀목공은 넷째 행이다."""
table = {
"headers": ["구 분", "단위", "적 용", "비 고", "", ""],
"rows": [
["자재", "강관 동바리", "내관(48.6mm×2.4mm)", "본", "0.38", ""],
["외관(60.6mm×2.3mm)", "본", "0.38", "", "", ""],
["잡재료비(재료비의)", "%", "5", "", "", ""],
["인력", "형틀목공", "인", "0.07", "", ""],
],
}
assert detect_form(table, "12")[0] == "requirement"
def test_비율_지시_한_줄이_소요량표를_참조로_넘기지_않을것() -> None:
"""⚠ 만들다 실제로 걸린 자리 — `잡재료비(재료비의) 5 %` 한 줄 때문에 강관동바리가
참조로 넘어갔다. **소요량을 먼저 보고** 비율 지시는 그 뒤에 본다."""
ratio_only = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [["재료비", "", ""], ["설치비", "재료비의 5%", ""]],
}
assert detect_form(ratio_only, "12")[0] == "reference"
def test_수치가_없으면_지어내지_않고_미판정으로_둘것() -> None:
"""`['재료비', 'JOINT FILLER', '']` — 이름만 있고 소요량이 없다."""
table = {"headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "JOINT FILLER", ""]]}
assert detect_form(table, "12")[0] == "undetermined"
def test_생산량형이_딱지_규칙에_안_밀릴것() -> None:
"""딱지 규칙은 **맨 마지막**에 본다 — 앞의 판정을 흔들면 안 된다."""
table = {
"headers": ["작업능력(㎥/hr)", "비고"],
"rows": [["자재", "보통인부 1인/일", "12.5"]],
}
assert detect_form(table, "9")[0] == "productivity"
# ── 딱지가 비율을 달고 오는 표 (2026-09-07 서브 창 제보로 확인) ─────
#
# `인력(10%)` · `장비(90%)` — 소요량형이면서 **장비 몫은 시공능력 공식**이라 값이 아니다.
# 형태 한 낱말로만 적으면 받는 쪽이 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다.
def test_몫이_적힌_표는_몫을_실을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["인력(10%)", "보통인부(인)", "0.23"],
["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)", "k", "0.9"],
],
}
assert resource_shares(table) == {"인력": 10.0, "장비": 90.0}
def test_몫이_없는_표는_빈칸() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares
assert resource_shares({"rows": [["보통인부(인)", "0.16"]]}) == {}
def test_몫이_있으면_부분값으로_표시될것() -> None:
"""⚠ 공식 기호가 **첫 표에만** 있고 이어지는 표는 물려받는다 — 기호가 있는 표만 세면
9-13-2 같은 절반을 놓친다. 넓게 잡되 **값을 지우지 않고 표시만** 한다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import (
capacity_formula_pending,
resource_shares,
)
inherited = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["인력 (10%)", "보통인부(인)", "0.26"],
["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)"],
],
}
assert resource_shares(inherited) # 몫은 있고
assert not capacity_formula_pending(inherited) # 공식 기호는 이 표에 없다
def test_형태_판정은_그대로_소요량형() -> None:
"""몫 표시를 더해도 앞의 판정을 흔들지 않는다."""
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [["인력(10%)", "보통인부(인)", "0.23"], ["장비(90%)", "유압식백호우"]],
}
assert detect_form(table, "9")[0] == "requirement"
# ── 밑수는 표 안이 아니라 **표 바로 위 본문**에 있다 (2026-09-07) ────
#
# 서브 창이 「기준 단위 없음으로 도는 단가가 125 중 122」를 보고했고 파 보니 뿌리가 여기였다.
# 「10㎡당」 표를 1㎡당으로 알면 **곱셈이 10배 틀린다.**
# ⚠ 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다.
def _basis(text_lines: list[str]) -> tuple[float | None, str | None]:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
# 마지막 줄이 표라고 보고, 그 줄 번호(1-기반)를 넘긴다.
return basis_from_source(text_lines, len(text_lines))
def test_본문에서_밑수를_읽을것() -> None:
assert _basis(["### 12-2. 표면 마무리", "", "(단위: ㎡당)", "", "| 구 분 |"]) == (1.0, "㎡")
assert _basis(["### 5-1-4. 떼채취", "", "(100㎡당)", "", "| 구 분 |"]) == (100.0, "㎡")
def test_분모가_밑수인_꼴() -> None:
"""`(단위: 인/㎡당)` — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다."""
assert _basis(["### 13-4-1. 메쌓기(인력)", "", "(단위: 인/㎡당)", "", "| 뒷길이 |"]) == (
1.0,
"㎡",
)
def test_당도_단위도_없으면_밑수가_아님() -> None:
"""⚠ 만들다 실제로 걸린 자리 — `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다."""
assert _basis(["| 장비 | 유압식백호우 (무한궤도,0.7㎥) |", "", "| 구 분 |"]) == (None, None)
def test_앞_표의_밑수를_물어오지_않을것() -> None:
"""위로 거슬러 보되 다른 표에 닿으면 멈춘다 — 남의 밑수를 물어 오면 조용히 틀린다."""
lines = ["(100㎡당)", "| 앞 표 |", "| 값 |", "", "### 9-21. 제근", "", "| 종 류 |"]
assert _basis(lines) == (None, None)
def test_묶음_기준_판정() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_quantity_is_grouped
assert basis_quantity_is_grouped(10.0) and basis_quantity_is_grouped(100.0)
assert not basis_quantity_is_grouped(1.0)
assert not basis_quantity_is_grouped(None)
def test_산출물에_밑수_미확보_목록이_나올것() -> None:
"""빈칸으로 두면 「1단위당」으로 오해된다 — 곱하면 안 되는 줄을 받는 쪽이 가릴 수 있게 낸다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "basis_missing_2026-01-01.json"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["items"]
# 곱할 값이 아닌 형태(참조·계수)는 목록에 안 담는다 — 잡음이 되면 안 본다.
assert {item["pum_form"] for item in payload["items"]} <= {"requirement", "productivity"}
def test_실물_묶음_기준이_실제로_실릴것() -> None:
"""13-6-2 찰쌓기는 원문이 `(단위: 10㎡당)` 이다 — 1 로 두면 10배 틀린다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0420"]["basis_quantity"] == 10.0
assert tables["F0420"]["basis_unit"] == "㎡"
assert tables["F0420"]["basis_source"] == "본문"
# ── 값 자리에 식이 적힌 칸 (2026-09-07 서브 창 제보) ────────────────
#
# `0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적혀 있으면 **값이 숫자로 안 읽혀
# 그 성분이 조용히 빠진다.** 형태 판정은 통과하므로 어떤 형태 검사에도 안 걸린다.
# ⚠ 식을 **계산하지 않는다** — 뜻을 잘못 읽으면 조용히 틀리므로 드러내기만 한다.
def test_식이_적힌_칸을_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["부설다짐", "할석공(인)", "0.6"],
["소할(30%)", "할석공(인)", "0.2 × 30%"],
],
}
assert expression_cells(table) == ["0.2 × 30%"]
def test_평범한_숫자는_식이_아님() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
assert expression_cells({"rows": [["보통인부(인)", "0.16"], ["계", "1,234.5"]]}) == []
def test_긴_설명문은_식이_아님() -> None:
"""⚠ 좁게 잡는다 — 비고의 설명문까지 걸면 목록이 잡음이 되어 결국 안 본다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
rows = [["비고", "- 지주목을 세우지 않을 때는 인력품의 10%를 감한다"]]
assert expression_cells({"rows": rows}) == []
def test_기초잡석이_실제로_잡힐것() -> None:
"""서브가 실물에서 부딪힌 그 표 — 부설다짐 0.6인만 서고 소할 몫이 빠져 있었다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0373"]["expression_cells"] == ["0.2 × 30%"]
# 배분율 딱지가 없어 `partial_ratio` 로는 안 걸리는 자리다 — 그래서 깃발이 따로 필요했다.
assert tables["F0373"]["partial_ratio"] is False
# ── 작업조 표 · 「단 위」 표지 제거 (2026-09-07 서브 창 제보) ────────
#
# ⚠ 「형틀목공 4인 + 보통인부 1인 / 시공량 35㎡」의 **「4」는 소요량이 아니라 인원**이다.
# 행-자원으로 그냥 읽으면 **35배 부푼다**. 값을 바꾸지 않고 표시만 한다.
def test_작업조_표를_알아볼것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table
table = {
"headers": ["구 분", "단 위", "수 량", "시 공 량 (㎡)"],
"rows": [
["복 잡", "보 통", "간 단"],
["형틀목공 보통인부", "인 인", "4 1", "25", "35", "40"],
],
}
assert crew_table(table) is True
def test_평범한_소요량표는_작업조가_아님() -> None:
"""⚠ 짝 시험 — 넓게 잡으면 멀쩡한 소요량표가 다 걸린다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table
assert (
crew_table({"headers": ["구 분", "단 위", "수 량"], "rows": [["미 장 공", "인", "0.34"]]})
is False
)
def test_헤더_단위_만으로_참조가_되지_않을것() -> None:
"""⚠ 이 시험이 이번 건의 핵심 — 「단위 열이 있다」는 소요량표의 흔한 모양이지
참조표의 표지가 아니다. 그 표지 하나로 **41건이 버려지고 있었고** 그 안에
**돌쌓기(장비) 13-4-5**(우리 매핑이 실제로 쓰는 코드)가 있었다."""
table = {
"headers": ["구 분", "단 위", "수 량"],
"rows": [["돌 쌓 기 공", "인", "0.32"], ["보통인부", "인", "0.28"]],
}
assert detect_form(table, "13")[0] == "requirement"
def test_돌쌓기_장비가_실제로_공종으로_설것() -> None:
"""매핑이 `masonry_wet → FP-13-04-05` 로 쓰는 표다 — 참조로 버려지면 단가가 안 선다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0414"]["pum_form"] == "requirement" # 13-4-5 찰쌓기(장비)
assert tables["F0407"]["pum_form"] == "requirement" # 13-4-2 메쌓기(장비)
def test_작업조_깃발이_산출물에_실릴것() -> None:
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0395"]["crew_table"] is True # 유로폼 설치·해체
assert tables["F0132"]["crew_table"] is True # 평떼 시비 — 서브가 실물에서 부딪힌 표
assert tables["F0334"]["crew_table"] is False # 표면 마무리는 평범한 소요량표
# ── 이름 표기 갈림 · 공식 기호 줄 (2026-09-07 서브 창 제보) ─────────
#
# ⚠ 같은 표 묶음 안에서도 표기가 갈린다 — `13-6-1` 은 「굴 삭 기 (무한궤도)」,
# 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」. 받는 쪽이 이름으로 자원을 찾으므로
# 표기가 갈리면 **그 줄이 통째로 빠진다.**
# ⚠⚠ **여기서 이름을 고치지 않는다.** 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** —
# 서브 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 사용료가 이중이 됐다.
def test_자간_공백_자원_이름을_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names
table = {
"rows": [["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84"], ["작업반장", "", "인", "0.83"]]
}
assert spaced_names(table) == ["굴 삭 기 (무한궤도)"]
def test_머리글_재료명은_안_걸릴것() -> None:
"""⚠ 짝 시험 — 「단 위」·「모 래」까지 걸면 101건이 되어 목록이 잡음이 된다.
**단위 칸과 수치가 함께 있는 자원 줄**만 본다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names
assert spaced_names({"rows": [["단 위", "㎥"], ["모 래", "6"]]}) == []
def test_공식_기호_줄을_찾을것() -> None:
"""`K`·`f`·`E` 는 시공능력 공식 파라미터다 — 자원으로 세면 이중계상."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import formula_rows
table = {"rows": [["k", "0.9"], ["f", "1/1.3"], ["보통인부", "인", "0.23"]]}
assert set(formula_rows(table)) == {"k", "f"}
def test_서브가_부딪힌_표가_실제로_잡힐것() -> None:
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0419"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-6-1 메쌓기
assert tables["F0423"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-7-2 찰붙이기
# 바로 옆 13-6-2 찰쌓기는 공백이 없다 — 그래서 표기가 갈린다.
assert tables["F0420"]["spaced_names"] == []
assert "k" in tables["F0258"]["formula_rows"] # 9-12-1 측구터파기 토사
# ── 규격 표기 특수문자 (2026-09-07 갈래 계약) ──────────────────────
#
# ⚠ 원문이 한 종류로 안 쓴다 — 물결표만 셋(`∼` 662 · `` 459 · `~` 2), 곱셈표 둘.
# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다.**
# 그래서 인계는 키를 조립하지 않고 **저장 원본값만** 보낸다. 여기서는 표시만 한다.
def test_특수문자_종류를_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs
table = {"rows": [["직경 40㎝이상∼60㎝미만", "600 x 1,200mm"]]}
found = special_glyphs(table)
assert "(U+223C)" in found and "x(U+0078)" in found
def test_평범한_글자는_안_걸릴것() -> None:
"""⚠ 짝 시험 — 넓게 잡으면 목록이 잡음이 된다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs
assert special_glyphs({"rows": [["보통인부", "인", "0.16"]]}) == []
def test_물결표가_두_종류_섞인_표가_실제로_있을것() -> None:
"""⚠ 이것이 계약을 바꾼 근거다 — 같은 절 안에서도 표기가 갈린다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
mixed = [
tid
for tid, t in tables.items()
if sum(1 for g in t.get("special_glyphs") or [] if "U+223C" in g or "U+FF5E" in g) >= 2
]
assert mixed, "물결표가 섞인 표를 못 찾았다 — 계약 근거가 사라졌는지 확인할 것"
# ── 참조 표지 — 41건을 지웠던 그 규칙의 짝 시험 (2026-09-08 ㉘) ────────────────
# 넓히면 정상 공종이 사라지고, 좁히면 기준표가 공종으로 선다. 양쪽을 다 박는다.
def _표(headers: list[str], rows: list[list[str]] | None = None) -> dict:
return {"headers": headers, "rows": rows or [["보통인부", "인", "1.0"]]}
def test_참조_표지가_기준표를_잡을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form
for mark in REFERENCE_MARKS:
form, why = detect_form(_표([mark, "구분", "비율"]), "13")
assert form == "reference", f"'{mark}' 를 못 잡는다 — {why}"
def test_단위_열이_있다고_공종을_버리지_말것() -> None:
"""⚠ 2026-09-07 사고 — 헤더 「단 위」 하나로 **41건**이 참조로 버려졌다.
그 안에 돌쌓기(장비) `13-4-5`·`13-4-2` 처럼 **우리 매핑이 실제로 쓰는 공종**이 있었다.
「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form
assert "단위" not in [m.replace(" ", "") for m in REFERENCE_MARKS]
form, _why = detect_form(_표(["명칭", "규격", "단 위", "수량"]), "13")
assert form != "reference"
def test_참조_표지를_부분일치로_넓히지_말것() -> None:
"""정상 공종 이름에 표지 글자가 스쳐도 걸리면 안 된다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
for headers in (["명칭", "규격", "단위", "수량"], ["직종", "단위", "수량"]):
form, _why = detect_form(_표(headers), "9")
assert form != "reference", headers
# ── 배수관 유입부 집수정 — 부속 줄 규칙의 짝 시험 (2026-09-08 ㉘) ──────────────
def test_부속은_그_칸이_있을_때만_줄이_선다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of
= {"structure_id": "s1", "type_id": "pipe", "options": {}}
assert attachments_of() == [], "집수정 형식을 안 골랐는데 줄이 섰다"
["options"] = {"inlet_basin_form": "돌집수정 ㄷ형"}
붙은것 = attachments_of()
assert [r["type_id"] for r in 붙은것] == ["pipe_inlet_basin"]
assert 붙은것[0]["attachment_of"] == "s1"
assert 붙은것[0]["structure_id"] != "s1", "부모와 같은 id 면 두 줄이 한 줄로 뭉친다"
def test_부속이_없는_종류에는_아무것도_안_붙는다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of
옹벽 = {"structure_id": "s2", "type_id": "retaining_wall", "options": {"inlet_basin_form": "ㄷ형"}}
assert attachments_of(옹벽) == []
# ── 빈 단가산출서 서식 (2026-09-08 ㉙, 서브가 찾아 준 것) ─────────────────────
def test_빈_단가산출서_서식은_공종이_아님() -> None:
"""품셈이 실어 둔 **채워 넣으라고 둔 양식** — 값이 아예 없다.
이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다.
실측: 이 규칙 하나로 견적 쪽 못 맞춘 자원이 **882 → 497** 로 줄었다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
= {
"headers": ["ha당 참나무시들음병방제 단가산출서(예시)", "", ""],
"rows": [["위치 및 임․소반", "1-0-1-0 또는 00임반 00소반", "비고"]],
}
form, why = detect_form(, "4")
assert form == "reference", why
def test_머리글이_없어도_서식_모양으로_가름() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
= {
"headers": [],
"rows": [
["위치 및 면적", "00임반 00소반", "비 고"],
["구분", "작업량", "단위품 (인원,수량,요율)", "소요품", "단가(원)"],
],
}
assert detect_form(, "3")[0] == "reference"
def test_서식_규칙이_정상_표를_지우지_말것() -> None:
"""⚠ 「구분」·「단가」는 정상 표에도 흔하다 — 하나만 보면 정상 표가 사라진다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
소요량표 = {"headers": ["명칭", "규격", "단위", "수량"], "rows": [["보통인부", "", "인", "1.0"]]}
assert detect_form(소요량표, "13")[0] == "requirement"
구분표 = {
"headers": [],
"rows": [["구 분", "소요인력", "인력구성"], ["작업로 예정선 선정", "1.0", "초급기술자"]],
}
assert detect_form(구분표, "3")[0] != "reference"
def test_마스터에_빈_서식이_공종으로_남아있지_않을것() -> None:
"""정본 대조 — 다시 만든 마스터에 그 표가 소요량표로 서 있으면 깨진다."""
import json
from pathlib import Path
master = json.loads(
(
Path(__file__).resolve().parents[2]
/ "resources"
/ "data_work_item_master"
/ "work_item_master_2026-01-01.json"
).read_text(encoding="utf-8")
)
남은것: list[str] = []
def walk(node: object) -> None:
if isinstance(node, dict):
for table in node.get("tables") or []:
head = "".join(
str(c) for row in (table.get("raw_row") or [])[:1] for c in row
).replace(" ", "")
if "위치및" in head and table.get("pum_form") != "reference":
남은것.append(str(table.get("pum_table_id")))
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(master)
assert not 남은것, f"빈 서식이 아직 공종으로 서 있다: {sorted(set(남은것))}"
# ── 밑수가 표 아래 [주] 에만 있는 자리 (2026-09-08, B09 제보) ──────────────────
def test_규준틀_밑수를_표_아래_주에서_읽는다() -> None:
"""⚠ 「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였다.
11-2·11-3 은 표에도 표 위 본문에도 밑수가 없고, [주]③ 「목재의 손율은
**1개소 사용당** 50%로 한다」에만 「개소」가 나온다. 뜻으로도 개소가 맞다 —
[주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·가설·철거를 포함한 것」이다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 11-2. 토공의 비탈 규준틀",
"| 종 류 | 단 위 | 수 량 |",
"|---|---|---|",
"| 건축목공 | 인 | 0.16 |",
"[주] ① 비탈길이 10m 이상 20m마다 설치한다.",
"- ③ 목재의 손율은 1개소 사용당 50%로 한다.",
]
assert basis_from_source(lines, 2) == (1.0, "개소")
def test_개소당_평균면적_같은_흔한_말에는_안_걸린다() -> None:
"""⚠ 넓히면 조림 2장이 통째로 걸린다 — 실측 19건. 「사용」이 있어야 잡는다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 2-1. 풀베기",
"| o 집단화 정도(1-4-3) | 개소당 평균면적이 1~3ha 미만 | 5% |",
]
assert basis_from_source(lines, 2) == (None, None)
def test_아래_주를_보되_다음_절까지_넘어가지_말것() -> None:
"""남의 [주] 를 물어 오면 조용히 틀린다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 11-9. 밑수가 없는 표",
"| 종류 | 단위 | 수량 |",
"|---|---|---|",
"### 11-2. 토공의 비탈 규준틀",
"- ③ 목재의 손율은 1개소 사용당 50%로 한다.",
]
assert basis_from_source(lines, 2) == (None, None)
def test_마스터의_규준틀_둘에_밑수가_실려있을것() -> None:
"""정본 대조 — 다시 만든 마스터에 값이 실제로 들어갔는가."""
import json
from pathlib import Path
master = json.loads(
(
Path(__file__).resolve().parents[2]
/ "resources"
/ "data_work_item_master"
/ "work_item_master_2026-01-01.json"
).read_text(encoding="utf-8")
)
찾음: dict[str, tuple] = {}
def walk(node: object) -> None:
if isinstance(node, dict):
code = node.get("work_item_code")
if code in ("FP-11-02", "FP-11-03"):
for table in node.get("tables") or []:
찾음[str(code)] = (table.get("basis_quantity"), table.get("basis_unit"))
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(master)
assert 찾음 == {"FP-11-02": (1.0, "개소"), "FP-11-03": (1.0, "개소")}, 찾음