Files
Aislo/resources/tester/test_704_4_spec.py
T
eomsangdonandClaude Opus 5.5 56e2c0b515 feat(규격 표 화면): 격자 규격 열 · M02 규격 표 편집 · 고르기 창 열 단추 (PLAN 704-4)
- 공용 격자 — 첫 호표 specs 면 수량 칸을 열마다(머리 「H 열」 · 기본 표시) · 합계 줄 그 칸 = 열마다 1 단위 합계
- M02 일위대가 카드 · 상세 — 격자 위 규격 줄(이름 · 열 이름 · 기본 · 열 지움 · [+ 열]) · 격자 수량 칸 열마다 고침(짝 셈 바로)
- 새 파일 M02_MasterTemplete_Labor_Spec.ts — 상세(700 줄)는 한 줄만 바꿈
- 고르기 창 — 규격 열 둘 이상이면 열 단추(열마다 합계) · [적용] = spec 붙은 참조 · 이름 뒤 「(H=열)」 · 열 하나 = 단추 없음
- 시험 704-3 격자 시험 두 줄 고침(laborGrid 넷째 인자 · Labor_Spec 이어 붙임) · 704-4 화면 자료 시험 둘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:23:46 +09:00

227 lines
11 KiB
Python

"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from common_util.common_util_summary_item import parse_summary_item
from config import config_system
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import SAMPLE
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
SPEC = {
"이름": "H",
"열": ["1.5", "2.5"],
"기본": "1.5",
"수량": {"GF000219": {"1.5": 2, "2.5": 3}},
}
LABOR = {
"종류": "일위대가",
"열": "ms",
"단위": "m",
"담은로직": [
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통
],
"자재": [],
"규격": SPEC,
}
# ── summary_item spec ──────────────────────────────────────────────────────
def test_summary_item_spec_있음_없음():
assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"}
assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로
with pytest.raises(ValueError, match="모르는 칸"):
parse_summary_item({**MS, "열": "2.5"})
for bad in ("", "x" * 21, 2.5):
with pytest.raises(ValueError, match="spec"):
parse_summary_item({**MS, "spec": bad})
# ── 굳히기 ─────────────────────────────────────────────────────────────────
def test_굳히기_고른_열_지운_열_옛_문서():
got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5")
assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5}
assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}}
assert "계산값" not in got
assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본
old = {k: v for k, v in LABOR.items() if k != "규격"}
assert spec.freeze(old, "2.5") is old
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
@pytest.fixture
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE)
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
(system / "labor").mkdir()
(system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8")
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
return {"system": system, "root": root}
def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]:
rows = [
{
"structure_id": f"st{n}",
"type_id": "masonry_dry",
"placement": "interval",
"chainage_m": 85 + n * 100,
"start_m": 80 + n * 100,
"end_m": 90 + n * 100,
"options": {"height_m": 2.0, "length_m": 10},
"summary_item": {**MS, **({"spec": s} if s else {})},
}
for n, s in enumerate(specs)
]
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
'{"points": []}', encoding="utf-8"
)
return snap.sync_snapshots(root)
def _block(root: Path, owner: str) -> tuple[dict, dict]:
got = daega_inputs(root)
return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner])
def test_사본이_고른_열을_굳힘_manifest_규격(world):
root = world["root"]
assert _save(root, ["2.5"])["만듦"] == ["st0"]
folder = snap.structures_dir(root) / "st0"
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"]
assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}]
manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5"
assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로
def test_열_바꿈_뒤_덮개_남음(world):
root = world["root"]
_save(root, ["1.5"])
_, before = _block(root, "st0")
cids = [ln["cid"] for ln in before["lines"]]
assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5]
cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침
got = _save(root, ["2.5"])
assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본
after_all, after = _block(root, "st0")
assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(after_all, sheets, {}, cover)
lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"]
assert lines[0]["qty"] == 3 # 새 열 수량
assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로
assert out["daega_orphans"] == []
def test_B07_한_장_판정_항목_더하기_열(world):
root = world["root"]
_save(root, ["1.5", "1.5", "2.5"])
ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")]
assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
def test_열_지움_쓰는_사본_찾기(world):
root = world["root"]
_save(root, ["2.5", None])
assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"]
assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == []
assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == []
def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
root = world["root"]
_save(root, ["2.5"])
path = world["system"] / "labor" / "ms.json"
with store.at(world["system"]):
with pytest.raises(store.StoreError) as got:
spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms")
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
spec.guard(path, {"규격": SPEC}, "ms")
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
def test_격자_규격_열_수량_칸_열마다():
from resources.tester import test_704_3_hopyo_grid as g
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}]
out = g._node(files, (
f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());"
"\nconst grid = hopyoGrid([b]);"
"\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group]),"
" rows: grid.rows, specs: b.specs }));"
), g.STUBS) # fmt: skip
cols = [c for c in out["cols"] if c[0].startswith("qty")]
assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]]
line = out["rows"][1] # 첫 구성 줄
assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09
mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌")
assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량
t15, t20 = out["specs"]["totals"]
assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합
assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖
def test_고르기_창_spec_돌려줌():
import subprocess
data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts"
body = (
data.read_text("utf-8")
+ """
const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"],
labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } };
const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } };
const plain = { key: "SS000001", 열: "rv" };
const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" };
console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"),
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)]));
"""
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
two, single, plain, *starts = json.loads(got.stdout)
assert two == {"layer": "system", "owner": None, "key": "SS000022",
"name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip
assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로
assert "spec" not in plain # 규격 없는 옛 항목
assert starts == ["1.5", "2.0", "2.0", None]