test(b09): 설계내역서 메뉴 판정 — 모든 줄 메뉴 · v2 덮개 차례 · 자동 줄 연결 단추에 맞춤 · sub2 셈 합친 뒤 이음매 확인(184-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-10 13:11:36 +09:00
co-authored by Claude Opus 5.5
parent e58aa972fc
commit 714955b7a9
2 changed files with 86 additions and 57 deletions
+36 -23
View File
@@ -195,37 +195,50 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit"])
# 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"])
rows = [
{"kind": "title", "key": "sec:1.2", "section": "1.2"},
{"kind": "item", "key": "earth:4", "section": "1.2"},
{"kind": "item", "key": "hand:b", "section": "1.2", "hand": True},
]
{"kind": "title", "key": "sec:1", "parent": None, "auto": True},
{"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True},
{"kind": "item", "key": "earth:4", "parent": "sec:1.2", "auto": True, "qty": 100,
"auto_qty": 5675},
{"kind": "item", "key": "hand:b", "parent": "sec:1.2", "hand": True},
] # fmt: skip
body = f"""
const rows = {json.dumps(rows, ensure_ascii=False)};
const first = {json.dumps(HAND[1], ensure_ascii=False)};
let user = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
const start = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
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 labels = (i) => billMenu(ctx, rows, i).map(name);
const out = {{ title: labels(0), auto: labels(1), hand: labels(2) }};
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "위에 줄 추가")[1]();
out.above = user.bill.rows[1];
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "수량 자동으로 되돌리기")[1]();
out.qty = user.bill.qty;
billMenu(ctx, rows, 2).find((x) => Array.isArray(x) && x[0] === "줄 지우기")[1]();
out.left = user.bill.rows.map((r) => [r.key.slice(0, 5), r.after]);
out.edit = ["name", "qty", "sum"].map(handEditable);
const run = (i, label) => {{
user = start;
billMenu(ctx, rows, i).find((x) => Array.isArray(x) && x[0] === label)[1]();
return user.bill;
}};
const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }};
out.above = run(2, "위에 줄 추가");
out.reset = run(2, "자동값으로 되돌리기");
out.left = run(3, "줄 지우기");
console.log(JSON.stringify(out));
"""
out = json.loads(_node(src + body))
assert out["title"] == ["아래에 줄 추가"]
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
assert out["qty"] == {}
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
assert out["left"] == [["hand:", "sec:1.2"]]
assert out["edit"] == [True, True, False]
add = ["위에 줄 추가", "아래에 줄 추가"]
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
above = out["above"]
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
new, earth, hand = above["rows"][2:]
assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2)
assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100}
assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"])
assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움
# 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만)
assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"]
assert out["left"]["gone"] == []
+50 -34
View File
@@ -177,46 +177,62 @@ 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;
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 = 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));
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_Edit"]) + body))
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") == ["묶음 줄로 바꾸기"] # 뒤에 형제(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
# g1 자손 끝 · 부모 = 셈 parent 그대로(184 공종 제목 키 — 옛 꼴 다듬기가 hand: 아닌 부모 버림)
assert (out["below"]["after"], out["below"]["parent"]) == ("hand:c", "sec:1.2")
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 "연결" 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"]) == (
@@ -224,12 +240,10 @@ def test_우클릭_레벨_메뉴_TS():
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
# g1 지움 — 자식은 g1 부모(공종) 아래 · 그 자리
assert out["removed"] == [BASE[0], ["hand:a", "sec:1.2"], ["hand:g2", "sec:1.2"], *BASE[4:]]
# 들여쓴 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))
rows2 = _rows(compute_estimation(INPUTS, {"bill": out["indentBill"]}, None, BOOKS))
assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230)
@@ -274,7 +288,7 @@ 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 → 일위대가 호표 고르기 창
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;
@@ -289,17 +303,18 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
.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"])
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"] # 비고 앞 「연결」 칸
assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호
# 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) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함
assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]]
# 누른 칸 아래(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
@@ -327,6 +342,7 @@ console.log(JSON.stringify(out));
def test_글_칸_위_우클릭도_줄_메뉴_TS():
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + ROW_MENU_JS))
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"]]]