Files
Aislo/resources/tester/test_z01_work_items.py
T
eomsangdonandClaude Opus 5 73f373a40d Merge: 데스크탑 서브(c0b22442 기계 규격 맞대기)와 한 점 — 자료는 다시 뽑음
- 시험 장부는 양쪽을 다 읽어 합침(서브 줄 25 살림) · 자료는 추출기로 다시 뽑음
- `_fuel` 은 내 판(겸용 연료·전력)을 취함 — 서브의 「㎾ 전용은 기름 값 없음」 뜻도 그 안에 담김
- 칸 토막은 서브 `_cells`(빈 칸 = 없음 · `동력` 뗌) 를 쓰되 **코드가 하나면 칸 통째로**
  (`1.87㎾ 휘발유1.3` 처럼 한 값 안에 공백)
- 8-1-7 손료 가산비율 이름 셋은 원문이 글로 뭉개져 이름 칸에 수가 들어앉음 — 목록에 사유로 둠
- 셈 — 건설 표 2,274 · 형태 미판정 166 · 밑수 확보 713 · 미확보 438 · 기계 손료 611 → 620
  · PDF↔md 구멍은 결손 749 · 허구 547 그대로(md 고치는 창 몫)
- 전체 시험 2,445 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-18 15:00:39 +09:00

316 lines
18 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.
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
return TestClient(app)
def _get(client: TestClient, kind: str, **params) -> dict:
res = client.get(f"/api/master-data/base-prices/{kind}", params=params)
assert res.status_code == 200, res.text
return res.json()
def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
listed = client.get("/api/master-data/base-prices").json()["kinds"]
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert (
forest["group"] == "work_item" and forest["rows"] == 624
) # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
# 2026-09-18 합침 620 → 624 — 원문이 풀리며 6-7-1 교목 시비 갈래 다섯이 새로 섬(사라진 것 0)
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
assert {i["group"]: i["group_label"] for i in listed} == {
"base_price": "기초단가",
"pumsem_basis": "품셈 기준",
"work_item": "공종 축",
}
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
assert table["total"] == 624 and table["stats"] == {"work_items": 360, "units": 624}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
assert len({r["@id"] for r in rows}) == 624
assert (
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계"
modes = [r["mode"] for r in table["rules"]]
assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3
blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05")
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
# 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②)
assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함"
assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름"
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
# 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거.
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_forest", q="발파암 ")
assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.
갈래도 산림과 같은 표 읽기로 폄(④ · 공종 997 → 줄 1,293 · 갈래 열쇠 `CW-00063#01`)."""
table = _get(client, "work_item_const", size=500, axis_role="")
# 2026-09-18 997/1,293 그대로 — 앞서 991/1,287 로 보였던 것은 **산출 파일이 옛 것**이어서였다
# (기계 다리가 선 뒤 다시 지으니 제자리로 돌아옴).
# 2026-09-18 랩탑 메인 — 건설 표 모양 판정층으로 미판정 891 → 276 이 되며 품 표가 늘어 줄도 늘었다.
# 2026-09-18 합침 — 다른 창이 원문 md 의 뭉친 줄·표를 더 풀어(건설 표 2,202 → 2,270) 공종·줄이 또 늘었다.
# 값이 바뀐 것이 아니라 **원문에 있던 표가 더 읽힌 것**이다(직접공사비 5,983,724 그대로).
assert table["stats"] == {"work_items": 1046, "units": 1502} and table["total"] == 1502
assert sum(1 for r in table["rows"] if "#" in r["@id"]) > 0
rows = table["rows"]
assert all(r["@id"].startswith("CW-") for r in rows)
assert all(r["name"].split(" ")[0].endswith("부문") for r in rows)
assert {r["mode"] for r in table["rules"]} == {"choose_one"}
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_const", q="굴착(인력/토사)")
assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"]
def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
folder = tmp_path / "data_work_item_master"
folder.mkdir()
fake = {
"dataset_version": {"dataset_id": "pum_const"},
"effective_date": "2026-01-01",
"work_items": [
{"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1",
"name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps",
"steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []},
{"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1",
"name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}],
"variant_keys": ["갑", "을"],
"variants": [{"variant_key": "01", "name": "갑", "name_clean": "갑"},
{"variant_key": "02", "name": "을", "name_clean": "을"}]},
],
} # fmt: skip
(folder / "const_work_item_master_2026-01-01.json").write_text(
json.dumps(fake, ensure_ascii=False), encoding="utf-8"
)
monkeypatch.setattr(work_items, "FOLDER", folder)
table = _get(client, "work_item_const")
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
assert table["rows"][0]["name"] == "토공 인력"
assert table["stats"] == {"work_items": 1, "units": 2}
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표)."""
const = _get(client, "work_item_const", size=1, axis_role="")
by_key = {f["key"]: f for f in const["filters"]}
assert set(by_key) == {"link", "division", "axis_role"}
division = by_key["division"]
assert division["default"] == ""
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
assert [o["value"] for o in division["options"][1:]] == [
"공통부문",
"토목부문",
"건축부문",
"기계설비부문",
"유지관리부문",
]
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`).
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
"general_provision"
] == "공종 아님 · 규정·기준"
assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아님
civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="")
assert civil["total"] == next(
o["rows"] for o in division["options"] if o["value"] == "토목부문"
)
assert all(r["name"].startswith("토목부문 ") for r in civil["rows"])
rules = _get(
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
)
assert rules["total"] > 0
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
assert all(
r["name"].startswith(("공통부문 적용기준", "공통부문 건설기계")) for r in rules["rows"]
)
role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}
# 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳
assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]}
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
"link",
"axis_role",
]
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None:
"""브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄.
기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음)."""
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
link_doc = json.loads(
sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8")
)
confirmed = {
key
for link in link_doc["links"]
if link["status"] == "확정"
for key in (link["forest_key"], link["const_key"])
if key
}
unconfirmed = {
key
for link in link_doc["links"]
if link["status"] != "확정"
for key in (link["forest_key"], link["const_key"])
if key
} - confirmed
for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")):
every = _get(client, kind, size=500, axis_role="")
rows = [
row
for page in range(1, every["total"] // 500 + 2)
for row in _get(client, kind, size=500, page=page, axis_role="")["rows"]
]
assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임
marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로
listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖
assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed}
assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상
assert not marked & unconfirmed
only = _get(client, kind, size=500, link="연결됨", axis_role="")
assert {r["@key"] for r in only["rows"]} == marked
box = next(f for f in every["filters"] if f["key"] == "link")
assert box["default"] == "" and box["options"][1]["rows"] == only["total"]
def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None:
"""2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름."""
rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"]
assert [r["variant"] for r in rows] == [
"1.0 이하", "1.11.5", "1.62.0", "2.12.5", "2.63.0",
"3.13.5", "3.64.0", "4.14.5", "4.65.0",
] # fmt: skip
# 열쇠에 갈래 **글자**를 담지 않는다 — 원문 물결표·자간이 다듬어지면 열쇠가 갈린다(브레인).
assert rows[1]["@id"] == "FW-00105#02" and rows[1]["@key"] == "FW-00105"
assert rows[1]["variant"] == "1.11.5" # 글자는 보일 이름 칸으로
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]
# 갈래 이름으로도 찾힘
assert _get(client, "work_item_forest", q="2.12.5")["total"] >= 1
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
for kind in ("work_item_forest", "work_item_const"):
shown = _get(client, kind, size=500)
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
assert role["default"] == "work_item"
counts = {o["value"]: o["rows"] for o in role["options"]}
assert counts["general_provision"] > 0
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
assert shown["total"] == counts["work_item"]
assert (
_get(client, kind, size=500, q="적용기준")["total"]
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
)
# 「전부」(빈 값을 보냄) → 총칙까지
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
assert (
_get(client, kind, size=1, axis_role="general_provision")["total"]
== (counts["general_provision"])
)
def test_찾기는_물결표_가운뎃점_띄어쓰기를_같게_봄(client: TestClient) -> None:
"""원문이 물결표를 둘(∼·~)로 쓰고 자간 공백이 들쭉날쭉 — 사람이 친 「~」·붙여 쓴 이름으로도 찾힘.
⚠ 찾기만 — 줄 값·열쇠는 원문 그대로(서브 훑기 · 브레인 2026-09-17)."""
wavy = _get(client, "work_item_forest", size=50, q="2.12.5")["total"]
assert wavy >= 1
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
assert _get(client, "work_item_forest", size=50, q="2.12.5")["total"] == wavy
rows = _get(client, "work_item_forest", size=50, q="2.1~2.5")["rows"]
assert all("~" not in r["@id"] for r in rows) # 열쇠는 손질 안 함
spaced = _get(client, "work_item_forest", size=50, q="토사 깍기", axis_role="")["total"]
assert (
spaced == _get(client, "work_item_forest", size=50, q="토사깍기", axis_role="")["total"] > 0
)
def test_이름_다듬기는_보일_이름만_열쇠는_그대로(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""⭐ 갈래 불변 열쇠를 세운 까닭(브레인 2026-09-17) — 자간 공백(「측 량」·「평 규 준 틀」)을 다듬어도
줄 열쇠(`FW-…#01`)가 한 글자도 안 흔들림. 다듬기를 끈 목록과 켠 목록의 열쇠를 맞댐."""
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Keys as keys
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
for kind in work_items.KINDS:
tidy = work_items.base_rows(kind)
with monkeypatch.context() as patch:
patch.setattr(keys, "tidy_spacing", lambda text: " ".join(str(text or "").split()))
raw = work_items.base_rows(kind)
assert [r["@id"] for r in tidy] == [r["@id"] for r in raw]
if kind == "work_item_const":
assert (
sum(a["name"] != b["name"] for a, b in zip(tidy, raw)) > 0
) # 실제로 다듬어진 줄이 있음
shown = _get(client, "work_item_const", size=50, q="기준점 측량", axis_role="")["rows"]
assert shown and all("측 량" not in r["name"] and " 측량 " in r["name"] for r in shown)
def test_품셈_번호는_마디마다_수로_세움(client: TestClient) -> None:
"""글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기) — 마디마다 수로."""
rows = _get(client, "work_item_forest", size=500, sort="number", axis_role="")["rows"]
numbers = [tuple(int(x) for x in r["number"].split("-")) for r in rows if "-" in r["number"]]
assert len(numbers) > 300 and numbers == sorted(numbers)
def test_건설_늘_빈_칸은_감춤(client: TestClient) -> None:
"""건설은 합산형 선언이 없어 「단계 무게」가 늘 빈칸 — 감춤(산림은 보임)."""
hidden = lambda kind: {c["key"]: c["hidden"] for c in _get(client, kind, size=1)["columns"]} # noqa: E731
assert hidden("work_item_const")["step_weight"] is True
assert hidden("work_item_forest")["step_weight"] is False