Files
Aislo/resources/tester/test_184_3_b09_bill_menu.py
T
eomsangdonandClaude Sonnet 5.5 88dd941d73 fix(B08·B09): 표 아래 안내 글 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0(236-1)
- B09 표 탭 안내 글 = 쓸모 있는 한 줄(lineHint) · 묶음 탭과 같은 줄임표
- B08 표 아래 안내 · 경고 줄 = footNote 한 줄 · 서버 알림은 한 줄로 이어 붙임
- 새 시험 test_236_1 · 창 높이별 창 끝까지 셈 헬퍼

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:09:56 +09:00

295 lines
16 KiB
Python

"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 ·
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
- 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone}
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
DG = {"tab": "dg", "key": "DG:a"}
def _row(key, parent, level, kind="item", cost="direct", **more):
auto = not key.startswith("hand:")
row = {"key": key, "kind": kind, "parent": parent, "level": level, "cost": cost, "no": "",
"name": key, "spec": "", "unit": "", "qty": None, "ref": None, "note": ""} # fmt: skip
return {**row, **({"auto": True} if auto else {"hand": True}), **more}
ROWS = [
_row("sec:1", None, 1, "title", no="1", name="토공사"),
_row("sec:1.2", "sec:1", 2, "title", no="1.1", name="절토"),
_row("earth:4", "sec:1.2", 3, qty=10, ref={"tab": "sg_list", "key": "D01341"}),
_row("earth:5", "sec:1.2", 3, qty=9, auto_qty=5),
_row("hand:g", "sec:1.2", 3, "group"),
_row("hand:a", "hand:g", 4, ref=DG, note="대가3"),
_row("hand:b", "sec:1.2", 3),
_row("sec:4", None, 1, "title", no="4", name="자재대"),
_row("mat:M1", "sec:4", 2, qty=3),
_row("sec:G", None, 1, "title", "supply", name="관급자재대"),
_row("mat:M2", "sec:G", 2, cost="supply", qty=4),
_row("fee", "sec:G", 2, cost="supply", qty=None),
]
#: 옛 꼴 덮개(166-2 · 182) — 자동 줄 수량 · 손 줄
OLD = {
"bill": {
"qty": {"earth:5": 9},
"rows": [
{"key": "hand:g", "section": "1.2", "after": "earth:5", "kind": "group", "name": "g"},
{"key": "hand:a", "section": "1.2", "after": "hand:g", "parent": "hand:g", "name": "a",
"qty": 2, "ref": DG},
{"key": "hand:b", "section": "1.2", "after": "hand:a", "name": "b", "qty": 1},
],
}
} # fmt: skip
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;
const item = billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label);
if (!item) return null;
item[1]();
return user.bill;
};
const edit = (key, field, value) => {
user = start;
editCell(ctx, rows, at(key), field, value);
return user.bill;
};
const out = { labels: {}, runs: {}, edits: {} };
for (const key of __LABELS__) out.labels[key] = labels(key);
for (const [key, label] of __RUNS__) out.runs[`${key}|${label}`] = run(key, label);
for (const [key, field, value] of __EDITS__) out.edits[`${key}|${field}`] = edit(key, field, value);
console.log(JSON.stringify(out));
"""
def _node(source: str) -> str:
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
)
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]
def _menu(user, labels=(), runs=(), edits=(), rows=ROWS):
body = (
MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
.replace("__USER__", json.dumps(user, ensure_ascii=False))
.replace("__LABELS__", json.dumps(list(labels), ensure_ascii=False))
.replace("__RUNS__", json.dumps([list(r) for r in runs], ensure_ascii=False))
.replace("__EDITS__", json.dumps([list(e) for e in edits], ensure_ascii=False))
)
return json.loads(_node(_ts(SRC) + body))
def _order(bill):
return [(e["key"], e.get("parent")) for e in bill["rows"]]
ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"]
def test_메뉴_모든_줄():
out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2",
"mat:M1", "sec:G"])["labels"] # fmt: skip
add = ["위에 줄 추가", "아래에 줄 추가"]
# 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움)
assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"]
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
"줄 지우기"] # fmt: skip
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
"줄 지우기"] # fmt: skip
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정
assert out["mat:M2"] == [*add, "새 공종 추가(1단)"]
assert out["mat:M1"] == [*add, "새 공종 추가(1단)"]
assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"]
def test_칸_고침_덮개_옛_꼴_굳힘():
out = _menu(OLD, edits=[("earth:4", "name", "절토A"), ("earth:5", "qty", 5),
("earth:4", "qty", 10), ("earth:4", "spec", None),
("hand:a", "qty", 7), ("sec:1.2", "name", "절토공")])["edits"] # fmt: skip
bill = out["earth:4|name"]
# 첫 손질 = 보이는 줄 키 · 부모 · 차례로 v2(조달수수료 빼고) · 옛 손 줄 칸 · 수량 덮개 옮김
assert (bill["v"], bill["gone"], _order(bill)) == (2, [], ORDER)
rows = {e["key"]: e for e in bill["rows"]}
assert rows["earth:4"] == {"key": "earth:4", "parent": "sec:1.2", "name": "절토A"}
assert rows["earth:5"] == {"key": "earth:5", "parent": "sec:1.2", "qty": 9}
assert rows["hand:g"] == {"key": "hand:g", "parent": "sec:1.2", "kind": "group", "name": "g"}
assert rows["hand:a"] == {"key": "hand:a", "parent": "hand:g", "name": "a", "qty": 2, "ref": DG}
assert "section" not in rows["hand:b"] and "after" not in rows["hand:b"]
# 자동 값과 같게 고침 = 덮개 지움 · 안 고친 값 그대로 = 덮개 안 생김 · null = 일부러 빈칸
assert "qty" not in {e["key"]: e for e in out["earth:5|qty"]["rows"]}["earth:5"]
assert "qty" not in {e["key"]: e for e in out["earth:4|qty"]["rows"]}["earth:4"]
assert {e["key"]: e for e in out["earth:4|spec"]["rows"]}["earth:4"]["spec"] is None
assert {e["key"]: e for e in out["hand:a|qty"]["rows"]}["hand:a"]["qty"] == 7
assert {e["key"]: e for e in out["sec:1.2|name"]["rows"]}["sec:1.2"]["name"] == "절토공"
V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in ORDER
if k != "hand:b"], "gone": []}} # fmt: skip
def test_지움_옮김_새_공종_자동값으로():
user = {"bill": {**V2["bill"], "rows": [
{**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip
runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"),
("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"),
("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"),
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"),
("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"),
("fee", "새 공종 추가(1단)")] # fmt: skip
out = _menu(user, runs=runs)["runs"]
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
base = keys(out["earth:5|위로 ↑"])
assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1
# 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로
b = out["earth:4|줄 지우기"]
assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b)
b = out["hand:g|줄 지우기"]
assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b)
# 위로 · 아래로 = 같은 부모 형제와 자리 바꿈
for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"):
got = keys(out[k])
assert got[got.index("earth:5") + 1] == "earth:4"
# 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞
b = out["earth:4|새 공종 추가(1단)"]
new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0]
assert (new["kind"], new["cost"], new["name"], "parent" in new) == (
"group", "direct", "새 공종", False) # fmt: skip
assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4"
b = out["mat:M2|새 공종 추가(1단)"]
assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:")
# 자동값으로 = 고친 칸 지움(차례 · 부모 그대로)
b = out["earth:5|자동값으로 되돌리기"]
assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"}
# 들여쓰기 · 내어쓰기
assert ("hand:b", "hand:g") in _order(out["hand:b|들여쓰기 →"])
got = _order(out["hand:a|내어쓰기 ←"])
assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2")
got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤)
assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0
# 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목)
got = out["sec:1.2|아래에 하위 줄 추가"]["rows"]
kids = [e for e in got if e.get("parent") == "sec:1.2"]
assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1]
got = out["earth:4|위에 줄 추가"]["rows"]
at = [e["key"] for e in got].index("earth:4")
assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2")
a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"]
assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None)
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
# 자동 줄 연결 풀기 = ref 덮개 null
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
# 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞
got = out["fee|위에 줄 추가"]["rows"]
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2
def test_관급_옮김_막힘_깊이_5():
# 차례 = 1 · 관급자재대 · 4 자재대(별도 자재 mat:M1) · 새 공종(자재 없음)
order = [*[(k, p) for k, p in ORDER if not k.startswith(("sec:4", "mat:M1"))],
("sec:4", None), ("mat:M1", "sec:4"), ("hand:n", None)] # fmt: skip
by = {r["key"]: r for r in ROWS}
rows = [by.get(k) or _row(k, None, 1, "group") for k, _ in order]
user = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in order],
"gone": []}} # fmt: skip
out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"]
assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음
assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대)
# 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음
deep = [
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
] # fmt: skip
out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"]
assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
assert "아래에 하위 줄 추가" in out["hand:g2"]
TABLE_JS = """
let user = __USER__;
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
get user() { return user; }, setUser: (u) => (user = u) };
const table = buildBill(ctx);
const at = (key) => table.rows.findIndex((r) => r.key === key);
const out = {
names: ["sec:1", "sec:1.2", "earth:4"].map((k) => table.rows[at(k)].name),
connect: ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"].map((k) => table.rows[at(k)].connect),
cls: [["earth:5", "qty"], ["earth:5", "name"], ["earth:4", "qty"]].map(([k, c]) => table.cellClass(at(k), c)),
tip: table.tip(at("earth:5"), "qty"),
edit: [["sec:1", "name"], ["sec:1", "qty"], ["earth:4", "spec"], ["fee", "qty"], ["fee", "name"]]
.map(([k, c]) => table.editable(at(k), c)),
hint: table.hint,
};
table.onEdit(at("sec:1.2"), "name", "1.1 절토공");
out.renamed = user.bill.rows.find((e) => e.key === "sec:1.2").name;
console.log(JSON.stringify(out));
"""
def test_표_칸_강조_번호_연결_칸():
body = TABLE_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)).replace(
"__USER__", "{}"
)
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no
assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""]
assert out["cls"] == ["is-edited", None, None]
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
assert out["edit"] == [True, False, True, True, False]
assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"]
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
def test_내역_초기화_배선():
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
block = src[src.index('label: "내역 초기화"') :]
block = block[: block.index("b09BillReset")]
# 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄
assert "showConfirmDialog(" in block and "if (!ok) return;" in block
assert "const { bill: _bill, ...rest } = user;" in block
assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block
assert "actions.append(save, reset);" in src