Files
Aislo/resources/tester/test_182_b09_bill_level.py
T

349 lines
16 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
# 184 — parent = 실제 부모 키(공종 바로 아래 = 그 제목 키)
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
2,
2,
"sec:3",
)
assert rows["hand:x"]["parent"] == "sec:1.2" and rows["hand:b"]["parent"] == "hand:g2"
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == ("sec:1.1", 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;
};
// 1.2 공종 차례 [키(새 줄 = NEW), 부모]
const order = (bill) => {
const list = bill.rows.map((r) => [r.name === "새 줄" ? "NEW" : r.key, r.parent ?? null]);
const from = list.findIndex(([k]) => k === "sec:1.2");
return list.slice(from + 1, list.findIndex(([k]) => k === "earth:5") + 1);
};
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 = order(run("hand:g2", "아래에 하위 줄 추가"));
out.below = order(run("hand:g1", "아래에 줄 추가"));
out.belowB = order(run("hand:b", "아래에 줄 추가"));
out.indentBill = run("hand:x", "들여쓰기 →");
out.indent = order(out.indentBill);
out.outdent = order(run("hand:g2", "내어쓰기 ←"));
out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key));
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x");
out.removed = order(run("hand:g1", "줄 지우기"));
console.log(JSON.stringify(out));
"""
#: 1.2 공종 기본 차례(셈 출력) — [키, 부모]
BASE = [
["earth:4", "sec:1.2"], ["hand:g1", "sec:1.2"], ["hand:a", "hand:g1"], ["hand:g2", "hand:g1"],
["hand:b", "hand:g2"], ["hand:c", "hand:g2"], ["hand:x", "sec:1.2"], ["earth:5", "sec:1.2"],
] # fmt: skip
def _put(order, at, *pairs):
return [*order[:at], *[list(p) for p in pairs], *order[at:]]
def test_우클릭_레벨_메뉴_TS():
# 184-3 — 모든 줄 메뉴 · 손질 = user.bill v2(차례 · 부모)
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_Link", "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") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음
assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목
assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지
assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1
assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움
assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤
assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모
assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"])
assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식
assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # 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 부모(공종) 아래 · 그 자리
assert out["removed"] == [BASE[0], ["hand:a", "sec:1.2"], ["hand:g2", "sec:1.2"], *BASE[4:]]
# 들여쓴 x = g1 금액에 듦(셈 짝 그대로)
rows2 = _rows(compute_estimation(INPUTS, {"bill": out["indentBill"]}, 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[2].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_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + body))
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
# 184-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) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
assert out["ref"] == DG
ROW_MENU_JS = """
const opened = [];
globalThis.createMapContextMenu = () => ({
element: { isConnected: true, style: {} },
open: (x, y, items) => opened.push({ x, y, items }),
});
const listeners = {};
const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true };
attachRowMenu(root, (i) => [["줄 " + i, () => {}]]);
const row = { dataset: { i: "7" } };
// 글 칸(input) 안쪽 우클릭 — 줄 칸 · 글 칸 모두 같은 줄 메뉴
const click = (inInput) => {
const ev = { target: { closest: (sel) => (sel === "tr[data-i]" ? row : inInput && sel === "input" ? {} : null) },
clientX: 5, clientY: 6, prevented: false, preventDefault() { this.prevented = true; } };
listeners.contextmenu(ev);
return ev.prevented;
};
const out = { onInput: click(true), onCell: click(false), opened: opened.map((m) => [m.x, m.y, m.items.map((i) => i[0])]) };
console.log(JSON.stringify(out));
"""
def test_글_칸_위_우클릭도_줄_메뉴_TS():
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + ROW_MENU_JS))
assert out["onInput"] is True and out["onCell"] is True
assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]]