From cb09ab96b214ea517d256c9f8b0d0b9137bd2e22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 00:14:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=88=98=EB=9F=89=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=86=90=20=EC=A4=84=20=EC=B9=B8=20=EA=B3=A0=EC=B9=A8=20=C2=B7?= =?UTF-8?q?=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EC=8B=9C=ED=97=98(166-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_Tabs_Bill — 항목 수량 덮어쓰기(빈칸 = 자동 · 풍선 자동 값) · 손 줄 명칭 · 규격 · 단위 · 수량 - UI_Shell — 내역서 표 우클릭 메뉴 붙임 · 고친 뒤 보던 자리 지킴 - Calc_Bill 짝 — 손 줄 자리(after) 놓침 고침 - 손 줄 띠 표시(CSS) · 새 시험 test_166_2(셈 · 거울 · 다듬기 · 새 산근 · 집계표 · 메뉴) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Calc_Bill.py | 5 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 5 +- B09_Estimation/B09_Estimation_UI_Page.css | 5 + B09_Estimation/B09_Estimation_UI_Shell.ts | 8 + B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 42 +++- resources/tester/test_166_2_b09_bill_hand.py | 231 ++++++++++++++++++ 6 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_166_2_b09_bill_hand.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 42c920ee4..92ff613a7 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -153,9 +153,8 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list return out -def _place(items: list[dict[str, Any]], hand: dict[str, Any]) -> None: +def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None: """손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝.""" - after = hand.get("after") or "" at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None) if at is None and after == f"sec:{hand['section']}": at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None) @@ -214,7 +213,7 @@ def compute_bill( prices = None if price is None else {"labor": 0, "material": price, "expense": 0} items.append(_item_row(item, prices, gaps, qty_of)) for item, prices, why in hand_items(user, books, hopyo): - _place(items, _item_row({**item, "why": why}, prices, gaps)) + _place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "") owner = [row for row in items if row["section"] == "G"] net = 0.0 for row in owner: diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index f7beb659a..5f6f5fa9a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -144,8 +144,7 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | } /** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */ -function place(items: Row[], hand: Row): void { - const after = hand.after || ""; +function place(items: Row[], hand: Row, after: string): void { let at = items.findIndex((row) => row.key === after); if (at >= 0) at += 1; else if (after === `sec:${hand.section}`) at = items.findIndex((r) => r.section === hand.section); @@ -206,7 +205,7 @@ export function computeBill( items.push(itemRow(item, prices, gaps, qtyOf)); } for (const [item, prices, why] of handItems(user, book, hopyo)) - place(items, itemRow({ ...item, why }, prices, gaps)); + place(items, itemRow({ ...item, why }, prices, gaps), item.after || ""); const owner = items.filter((row) => row.section === "G"); let net = 0; for (const row of owner) net += row.material || 0; diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 5766632f4..6c61a2550 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -110,3 +110,8 @@ .b09-sg-card__cols .b09-sg-card__num { font-variant-numeric: normal; } + +/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ +.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { + background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index e61193429..3b6efb1ad 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -48,6 +48,7 @@ import { buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; +import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { buildSumExpense, buildSumLabor, @@ -170,7 +171,11 @@ export function renderB09Estimation(root: HTMLElement): void { user = next; if (projectId) writeState("b09-estimation", user, projectId); recompute(); + // 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄) + const top = panel.querySelector(".ui-data-table")?.scrollTop ?? 0; show(current); + const box = panel.querySelector(".ui-data-table"); + if (box && top) box.scrollTop = top; }; /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ @@ -217,6 +222,9 @@ export function renderB09Estimation(root: HTMLElement): void { }, }); made.root.dataset.b09Table = tab; + // 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임 + const menu = (spec as BillTable).menu; + if (menu) attachRowMenu(made.root, menu); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title if (spec.tip) made.root.addEventListener("mouseover", (event) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 020e0c3f5..f7fa8dc8e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -3,23 +3,25 @@ * B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고). * * 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본) - * 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음) + * 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) · + * 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음) * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) + * 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs"; import { - money, - parseNumber, - qty, - text, - type TabBuild, - type TabId, - type TabTable, -} from "./B09_Estimation_UI_Tabs"; + billMenu, + editQty, + editText, + handEditable, + type BillTable, +} from "./B09_Estimation_UI_Tabs_Bill_Edit"; -export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => { +export const buildBill: TabBuild = (ctx): BillTable => { + const { sheets, user, setUser } = ctx; const bill: Row = sheets.bill || { rows: [], totals: null }; const rows: DataRow[] = bill.rows.map((r: Row) => ({ ...r, @@ -47,14 +49,30 @@ export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => { frozen: 1, hint: "공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " + + "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, - editable: (i, key) => key === "qty" && rows[i]?.key === "fee", + editable: (i, key) => { + const r = rows[i]; + if (!r || r.kind !== "item") return false; + return r.hand ? handEditable(key) : key === "qty"; + }, onEdit: (i, key, value) => { - if (key !== "qty" || rows[i]?.key !== "fee") return; + const r = rows[i]; + if (!r) return; + if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value); + if (key !== "qty") return; const v = parseNumber(value); if (v === undefined) return; + if (r.key !== "fee") return editQty(ctx, r, v); setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } }); }, + tip: (i, key) => { + const r = rows[i]; + if (key !== "qty" || !r || !("qty_auto" in r)) return null; + return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`; + }, + rowClass: (i) => (rows[i]?.hand ? "is-hand" : null), + menu: (i) => billMenu(ctx, rows, i), link: (i) => { const ref = rows[i]?.ref as Row | null | undefined; return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null; diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py new file mode 100644 index 000000000..0b0398f19 --- /dev/null +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -0,0 +1,231 @@ +"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴. + +- 수량 = `user.bill.qty[줄 키]` 가 있으면 그 값(`qty_auto` = 자동 값) · 키 지우면 자동(B08) +- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) · + 단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) · + 비고 = 「대가n」 · 「산근 n호표」 +- 새 산근(M01 로직) = 사본 산근 뒤 번호 · 쓰는 곳 = 「손 줄 …」 · 같은 로직 · 입력 = 같은 산근 +- 호표 탭 연동 = 손 줄이 연결한 산근이 단가산출근거(목록)에 실리고 집계표 역전개에 듦 +""" + +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_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import WHY_LOGIC, compute_estimation # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Store import clean_user # noqa: E402 +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡ +NET = "GC000337" +NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} + +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": 5675, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972, + "ref": None, "why": "조합 밖 토공 산근 — 연결 없음"}, +] # 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}], + "sg_list": [{"no": 1, "key": "SG:x", "labor": 500, "material": 0, "expense": 7, "sum": 507}, + {"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}], + "sg_hand": {"hand:c": "SG:new"}, +} # fmt: skip +HAND = [ + {"key": "hand:a", "section": "1.1", "after": "earth:1", "name": "제근", "spec": "", + "unit": "㎡", + "qty": 10, "ref": {"tab": "dg", "key": "DG:a"}}, + {"key": "hand:b", "section": "1.2", "after": "sec:1.2", "name": "벌개", "spec": "", + "unit": "㎡", + "qty": 4, "ref": {"tab": "sg", "key": "SG:x"}}, + {"key": "hand:c", "section": "1.2", "after": "없는줄", "name": "메쌓기", "spec": "", + "unit": "㎡", + "qty": 2.5, "ref": {"tab": "logic", "code": STONE, "given": {}, "rate": None, "picks": None}}, + {"key": "hand:d", "section": "1.2", "after": "hand:b", "name": "새 산근", "spec": "", + "unit": "㎡", "qty": 3, "ref": {"tab": "logic", "code": NET, "given": NET_IN}}, +] # fmt: skip +USER = {"bill": {"qty": {"earth:4": 100}, "rows": HAND}} + + +def _rows(sheets): + return {row["key"]: row for row in sheets["bill"]["rows"]} + + +def _node(source: str) -> str: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", 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_수량_덮어쓰기_자동_값_남김_지우면_자동(): + rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS)) + row = rows["earth:4"] + assert (row["qty"], row["qty_auto"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100 + plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"] + assert plain["qty"] == 5675 and "qty_auto" not in plain + # 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김 + rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS)) + assert rows["earth:1"]["sum"] == 25821 + + +def test_손_줄_자리_단가_비고(): + sheets = compute_estimation(INPUTS, USER, None, BOOKS) + keys = [r["key"] for r in sheets["bill"]["rows"] if r["section"] in ("1.1", "1.2")] + assert keys == [ + "sec:1.1", "earth:1", "hand:a", # earth:1 뒤 + # 제목 = 첫 자리 · 못 찾음 = 끝 + "sec:1.2", "hand:b", "hand:d", "earth:4", "earth:5", "hand:c", + ] # fmt: skip + rows = _rows(sheets) + a = rows["hand:a"] + assert (a["note"], a["ref"], a["hand"]) == ("대가3", {"tab": "dg", "key": "DG:a"}, True) + assert (a["labor"], a["material"], a["expense"], a["sum"]) == (10000, 2000, 300, 12300) + assert rows["hand:b"]["note"] == "산근 1호표" and rows["hand:b"]["sum"] == 2028 + c = rows["hand:c"] # 로직 = 서버가 준 sg_hand 산근 + assert (c["note"], c["ref"], c["sum"]) == ("산근 2호표", {"tab": "sg", "key": "SG:new"}, 27) + d = rows["hand:d"] # 새 산근 [저장] 전 = 빈칸 + 까닭 + assert d["sum"] is None and d["ref"] is None + assert ("bill", "hand:d", WHY_LOGIC) in {(g["tab"], g["key"], g["why"]) for g in sheets["gaps"]} + assert rows["sec:1.1"]["sum"] == 361494 + 12300 # 제목 합에 손 줄 + assert rows["sec:1.2"]["blank"] == 2 # 절토(연암) · 새 산근 + + +def test_거울_TS_가_같은_값(): + calc = _ts(["Calc_Base", "Calc_Bill"]) + for user in (USER, {}, {"bill": {"qty": {"earth:5": None}}}): + 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_저장_다듬기_bill(): + raw = { + "bill": { + "qty": {"earth:4": 100, "earth:5": "x", "st:a#대가1": 2.5}, + "rows": [ + *HAND[:1], + {"key": "x:1", "section": "1.1"}, # hand: 아님 + {"key": "hand:z", "section": "9"}, # 모르는 공종 + {**HAND[2], "ref": {**HAND[2]["ref"], "given": {"a": 1, "b": [1]}}, "junk": 1}, + {**HAND[1], "ref": {"tab": "zz", "key": "q"}}, + ], + } + } + bill = clean_user(raw)["bill"] + assert bill["qty"] == {"earth:4": 100, "st:a#대가1": 2.5} + assert [r["key"] for r in bill["rows"]] == ["hand:a", "hand:c", "hand:b"] + assert bill["rows"][1]["ref"] == {"tab": "logic", "code": STONE, "name": "", "given": {"a": 1}, + "rate": None, "picks": None} # fmt: skip + assert "junk" not in bill["rows"][1] and bill["rows"][2]["ref"] is None + assert "bill" not in clean_user({"bill": {"qty": {}, "rows": []}}) + + +def _snapshot(folder: Path, rows, values=None) -> Path: + folder.mkdir(parents=True, exist_ok=True) + files = { + "단가.json": item_prices.to_json(item_prices.collect([STONE, NET])), + "일위대가.json": {"줄": {"종류": "일위대가", "담은로직": rows}}, + "입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()}, + } + for name, data in files.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return folder + + +def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) + user = {"bill": {"rows": [{**HAND[2], "qty": 10}, HAND[3]]}} + hand_keys = sg.hand_resource_keys(user) + assert {"LB000033", "LB000002"} <= set(hand_keys) # 석공 · 보통인부 → 단가 책 수요 + keys = list(dict.fromkeys([*used_keys(a), *hand_keys])) + base = compute_base(prices.build_inputs(None, keys), {}) + got = sg.collect([("s1", a)], base, sg.hand_sources(user)) + listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]] + assert listed == [(1, NET, ["s1", "손 줄 새 산근"]), (2, STONE, ["손 줄 메쌓기"])] + assert got["sg_hand"] == { + "hand:c": got["sg_list"][1]["key"], + "hand:d": got["sg_list"][0]["key"], + } + stone = got["sg"][1] + assert stone["sum"] is not None and stone["why"] is None + # 내역서 — 손 줄 단가 = 새 산근 · 비고 「산근 2호표」 · 집계표 역전개에 듦 + books = {"daega": [], "sg_list": got["sg_list"], "sg_hand": got["sg_hand"]} + sheets = {**compute_estimation({**INPUTS, "bill": {"items": []}}, user, None, books), **got} + row = _rows(sheets)["hand:c"] + assert (row["note"], row["price_sum"]) == ("산근 2호표", stone["sum"]) + labor = compute_summary(sheets, hopyo_lines(sheets))["sum_labor"] + qty = {r["key"]: r["qty"] for r in labor} + assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망) + + +def test_우클릭_메뉴_줄_추가_지우기_연결_TS(): + src = _ts(["UI_Tabs", "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}, + ] + 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 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); +console.log(JSON.stringify(out)); +""" + out = json.loads(_node(src + body)) + assert out["title"] == ["아래에 줄 추가"] + assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"] + assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-", + "줄 지우기"] # fmt: skip + 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]