Files
Aislo/resources/tester/test_182_b09_bill_level.py
T
eomsangdonandClaude Opus 5.5 4da01d7435 feat(b09): 설계내역서 「연결 ▾」 칸 단추 · 우클릭과 같은 연결 메뉴 (182-2)
- 비고 앞 「연결」 칸 — 손 항목 줄만 「연결 ▾」 · 연결됨 = 호표 번호 ▾ · 누름 = 그 칸 아래 연결 메뉴
- 메뉴 = 일위대가 호표 · 단가산출 호표 · 단가산출 로직 + 로직 입력값 · 연결 풀기(166-2 고르기 창 재사용)
- 단추 칸 = 이동 안 함 · 묶음 줄 · 자동 줄 = 안 엶
- 시험 test_182 연결 단추(칸 · 메뉴 · 고르기 창 → ref) · node 입력 = 표준입력(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-10 11:55:45 +09:00

303 lines
14 KiB
Python

"""182 B09 설계내역서 손 줄 레벨 — 묶음 줄 · 부모 · 상위 합계 · 거울 · 저장 왕복 · 우클릭 레벨 메뉴.
- 손 줄 `kind: "group"` = 묶음 줄(수량 · 연결 없음) · `parent` = 같은 공종 묶음 손 줄 키
- `level` = 제목(1 · 2) · 줄 = 공종 깊이 + 1 + 손 묶음 조상 수 · 부모 못 찾음 · 다른 공종 · 돎 = 공종 바로 아래
- 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합 · `sums` = 항목만(원가계산서 그대로)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
ITEMS = [
{"key": "earth:1", "section": "1.1", "name": "규준틀", "unit": "개소", "qty": 14,
"ref": {"tab": "dg_list", "key": "B00194"}},
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None},
{"key": "haul:1", "section": "3", "name": "운반", "unit": "㎥", "qty": 2,
"ref": {"tab": "sg_list", "key": "D01341"}},
] # fmt: skip
HOPYO = {
"B00194": {"labor": 21936, "material": 3885, "expense": 0},
"D01341": {"labor": 1116, "material": 360, "expense": 463},
}
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO}
BOOKS = {
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
"sum": 1230}],
} # fmt: skip
DG = {"tab": "dg", "key": "DG:a"}
def _hand(key, after, section="1.2", **more):
row = {"key": key, "section": section, "after": after, "name": key[5:], "spec": "",
"unit": "㎡", "qty": None, "ref": None} # fmt: skip
return {**row, **more}
HAND = [
_hand("hand:g1", "earth:4", kind="group"),
_hand("hand:a", "hand:g1", parent="hand:g1", qty=10, ref=DG),
_hand("hand:g2", "hand:a", kind="group", parent="hand:g1"),
_hand("hand:b", "hand:g2", parent="hand:g2", qty=2, ref=DG),
_hand("hand:c", "hand:b", parent="hand:g2", qty=1), # 연결 없음 = 빈 줄
_hand("hand:x", "hand:c", parent="hand:없음", qty=1, ref=DG), # 부모 없음 → 공종 아래
_hand("hand:y", "haul:1", "3", parent="hand:g1", qty=1, ref=DG), # 다른 공종 부모
_hand("hand:p", "earth:1", "1.1", kind="group", parent="hand:q"), # 돌기
_hand("hand:q", "hand:p", "1.1", kind="group", parent="hand:p"),
]
USER = {"bill": {"rows": HAND}}
#: 같은 손 줄 · 묶음 없이(묶음 줄 빼고 부모 지움) — 공종 합 · sums 비교 밑
FLAT = {
"bill": {
"rows": [
{k: v for k, v in h.items() if k != "parent"} for h in HAND if h.get("kind") != "group"
]
}
}
def _rows(sheets):
return {row["key"]: row for row in sheets["bill"]["rows"]}
def _node(source: str) -> str:
# 표준입력으로 줌 — 「-e」 는 윈도 명령 줄 길이(32k)를 넘음
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def _ts(names: list[str]) -> str:
return "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in names
)
def test_레벨_차례_부모():
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
got = [(r["key"], r["level"]) for r in sheets["bill"]["rows"] if r["section"] == "1.2"]
assert got == [
("sec:1.2", 2), ("earth:4", 3), ("hand:g1", 3), ("hand:a", 4), ("hand:g2", 4),
("hand:b", 5), ("hand:c", 5), ("hand:x", 3), ("earth:5", 3),
] # fmt: skip
rows = _rows(sheets)
assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
2,
2,
None,
)
assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2"
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 3)
assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4)
# 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦
g1 = rows["hand:g1"]
assert (g1["kind"], g1["qty"], g1["ref"], g1["hand"]) == ("group", None, None, True)
assert not {g["key"] for g in sheets["gaps"]} & {"hand:g1", "hand:g2", "hand:p", "hand:q"}
def test_묶음_금액_자손_합_공종_합_그대로():
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
rows = _rows(sheets)
g2, g1 = rows["hand:g2"], rows["hand:g1"]
assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60)
assert (g2["blank"], g2["note"]) == (1, "빈 줄 1")
assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄)
assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == ""
# 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈)
flat = compute_estimation(INPUTS, FLAT, None, BOOKS)
titles = [r for r in sheets["bill"]["rows"] if r["kind"] == "title"]
assert titles == [{**r} for r in flat["bill"]["rows"] if r["kind"] == "title"]
assert sheets["bill"]["sums"] == flat["bill"]["sums"]
assert sheets["bill"]["totals"] == flat["bill"]["totals"]
assert rows["sec:1.2"]["sum"] == 19390 + 12300 + 2460 + 1230 # earth:4 + a + b + x
def test_거울_TS_가_같은_값():
calc = _ts(["Calc_Base", "Calc_Bill"])
for user in (USER, FLAT, {}):
body = (
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));"
)
assert json.loads(_node(calc + body)) == compute_estimation(INPUTS, user, None, BOOKS)
def test_저장_왕복_kind_parent(tmp_path):
raw = {
"bill": {
"rows": [
{**HAND[0], "qty": 5, "ref": DG}, # 묶음 줄 = 수량 · 연결 버림
HAND[1],
{**HAND[3], "parent": "x:1", "kind": "zz"}, # hand: 아닌 부모 · 모르는 꼴 버림
]
}
}
clean = store.write_user(tmp_path, raw)
assert store.read_user(tmp_path)["bill"] == clean["bill"]
g1, a, b = clean["bill"]["rows"]
assert (g1["kind"], g1["qty"], g1["ref"], "parent" in g1) == ("group", None, None, False)
assert (a["parent"], "kind" in a, a["ref"]) == ("hand:g1", False, DG)
assert "parent" not in b and "kind" not in b
# 다시 읽은 값으로 셈 = 원래 값으로 셈
back = store.read_user(tmp_path)
assert _rows(compute_estimation(INPUTS, back, None, BOOKS))["hand:g1"]["sum"] == 12300
MENU_JS = """
const rows = __ROWS__;
const start = __USER__;
let user = start;
const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) };
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
const at = (key) => rows.findIndex((r) => r.key === key);
const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
const run = (key, label) => {
user = start;
billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1]();
return user.bill.rows;
};
const out = {
g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"),
b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"),
};
out.child = run("hand:g2", "아래에 하위 줄 추가").at(-1);
out.below = run("hand:g1", "아래에 줄 추가").at(-1);
out.belowB = run("hand:b", "아래에 줄 추가").at(-1);
out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x");
out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2");
out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key));
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x");
out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key));
console.log(JSON.stringify(out));
"""
def test_우클릭_레벨_메뉴_TS():
rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace(
"__USER__", json.dumps(USER, ensure_ascii=False)
)
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit"]) + body))
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"]
assert "연결" not in out["g1"] and pick("g1") == ["항목으로 바꾸기"]
assert pick("a") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음
assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내
assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음
assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
assert out["indent"]["parent"] == "hand:g1"
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [
("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip
assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == (
"group",
None,
None,
)
# g1 지움 — 자식은 공종 아래로 · 바로 뒤 줄은 g1 자리(earth:4 뒤)로
assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [
("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip
# 들여쓴 x = g1 금액에 듦(셈 짝 그대로)
user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}}
rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS))
assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230)
LINK_JS = """
const opened = [];
const picked = {};
globalThis.createMapContextMenu = () => ({
element: { isConnected: true, style: {} },
open: (x, y, items) => opened.push({ x, y, items }),
});
globalThis.requestAnimationFrame = () => 0;
globalThis.createFindList = () => ({
search: { value: "", addEventListener() {}, focus() {} },
hint: {},
root: {},
setRows: (rows) => (picked.rows = rows),
});
globalThis.openModal = (o) => {
picked.title = o.title;
o.mount({ append() {} }, () => (picked.closed = true));
};
let user = __USER__;
const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {},
get user() { return user; }, setUser: (u) => (user = u) };
const listeners = {};
const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true };
attachRowMenu(root, () => []);
const cell = { getBoundingClientRect: () => ({ left: 10, bottom: 40 }) };
listeners.click({ target: { closest: () => cell } });
const table = buildBill(ctx);
const at = (key) => table.rows.findIndex((r) => r.key === key);
const name = (x) => (x === "-" ? "-" : x[0]);
const out = {
columns: table.columns.map((c) => c.key).slice(-2),
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
};
table.onCell(at("hand:a"), "connect");
table.onCell(at("hand:g1"), "connect");
table.onCell(at("earth:4"), "connect");
table.onCell(at("hand:c"), "connect");
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창
picked.rows[0].onPick();
out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)];
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
console.log(JSON.stringify(out));
"""
def test_연결_단추_칸_메뉴_고르기_창_TS():
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
body = (
LINK_JS.replace("__USER__", json.dumps(USER, ensure_ascii=False))
.replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False))
.replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False))
)
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + body))
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
assert out["cls"] == [
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
# 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함
assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]]
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
assert out["ref"] == DG