From c9cd2812dc50cb6a3eac59851071fef14aeff8c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:17:50 +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=EA=B8=80=20=EC=99=BC=EC=AA=BD=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EB=A0=88=EB=B2=A8=20=3D=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=EB=8F=84=EA=B5=AC=20=EC=A4=84(=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EB=84=B7=20=C2=B7=20Tab=20=C2=B7=20Shift+Tab=20?= =?UTF-8?q?=C2=B7=20Alt+=E2=86=91=E2=86=93=20=C2=B7=20=EC=95=88=EB=82=B4)?= =?UTF-8?q?=20(200-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 57 +++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 129 +++++++++++ .../tester/test_200_2_b09_bill_level_bar.py | 213 ++++++++++++++++++ 4 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts create mode 100644 resources/tester/test_200_2_b09_bill_level_bar.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 9f9a4b97a..1b24e484c 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -88,6 +88,63 @@ background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } +/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */ +.b09-estimation [data-b09-table="bill"] tbody tr { + --b09-lv: 0; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 { + --b09-lv: 1; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 { + --b09-lv: 2; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 { + --b09-lv: 3; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 { + --b09-lv: 4; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 { + --b09-lv: 5; +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td { + font-size: inherit; +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child { + padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child { + padding-left: 0; +} + +.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input { + padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); +} + +.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) { + text-align: left; +} + +/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */ +.b09-estimation__level { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.b09-estimation__level .b09-estimation__hint { + margin: 0; +} + /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ .b09-estimation .ui-data-table__table tbody tr.is-pick td { --pick-t: inset 0 0 0 0 transparent; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index cc619ea1f..fbc860214 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -52,6 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -249,8 +250,11 @@ export function renderB09Estimation(root: HTMLElement): void { ...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }), ); } - const table = makeTable(); - panel.replaceChildren(...(hint ? [hint] : []), table.root); + // 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 + const bill = (spec as BillTable).menu ? (spec as BillTable) : null; + const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); + const level = bill ? levelBar(table.root, bill) : null; + panel.replaceChildren(...(hint ? [hint] : []), ...(level ? [level] : []), table.root); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts new file mode 100644 index 000000000..ca070d200 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -0,0 +1,129 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Level.ts + * B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄. + * + * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 + * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) + * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) + * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */ +const ACTS: [string, string][] = [ + ["→ 들여쓰기", "들여쓰기 →"], + ["← 내어쓰기", "내어쓰기 ←"], + ["↑ 위로", "위로 ↑"], + ["↓ 아래로", "아래로 ↓"], +]; + +const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김"; +const GUIDE_MORE = + "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + + "들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + + "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + + "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; + +let picked: string | null = null; +/** 지금 그린 도구 줄 — 키가 부름 */ +let live: { + root: HTMLElement; + bar: HTMLElement; + run: (label: string) => void; + sync: () => void; +} | null = null; +let keysOn = false; + +/** 고른 줄 class — Shell 줄 class 곁값 */ +export const pickedClass = (row: DataRow | undefined): string | null => + row && picked !== null && String(row.key) === picked ? "is-focus" : null; + +/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ +export function menuActs(table: BillTable, index: number): Map void> { + const items = index >= 0 && table.menu ? table.menu(index) : []; + return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x))); +} + +/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */ +export function keyAct( + e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean }, + editing: boolean, +): string | null { + if (editing || e.ctrlKey) return null; + if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1]; + if (!e.altKey || e.shiftKey) return null; + return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null; +} + +const editing = (at: Element | null): boolean => + /^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") || + !!(at as HTMLElement | null)?.isContentEditable; + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || picked === null) return; + const at = document.activeElement; + if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return; + // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 + if (event.key === "Escape") { + if (!editing(at)) { + picked = null; + return live.sync(); + } + (at as HTMLElement).blur(); + return live.root.focus(); + } + const label = keyAct(event, editing(at)); + if (!label) return; + event.preventDefault(); + live.run(label); + }); +} + +/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */ +export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { + const index = (): number => + picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); + const run = (label: string): void => menuActs(table, index()).get(label)?.(); + const buttons = ACTS.map(([text, label]) => { + const button = createButton({ + label: text, + variant: "ghost", + size: "sm", + onClick: () => run(label), + }); + button.dataset.b09Level = label; + return button; + }); + const guide = document.createElement("span"); + guide.className = "b09-estimation__hint"; + guide.textContent = GUIDE; + attachHoverInfo(guide, GUIDE_MORE); + const bar = document.createElement("div"); + bar.className = "b09-estimation__level"; + bar.append(...buttons, guide); + const sync = (): void => { + const acts = menuActs(table, index()); + ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); + for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); + }; + root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; + if (!row) return; + picked = String(row.key); + sync(); + }); + sync(); + live = { root, bar, run, sync }; + listenKeys(); + return bar; +} diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py new file mode 100644 index 000000000..b2e0936b5 --- /dev/null +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -0,0 +1,213 @@ +"""200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 · +글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. + +- 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로 +- 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시 +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" + +BAR_JS = """ +class FakeEl { + constructor(tag) { + this.tagName = tag.toUpperCase(); this.kids = []; this.dataset = {}; this.cls = new Set(); + this.on = {}; this.parent = null; this.isConnected = true; this.disabled = false; this.textContent = ""; + } + set className(v) { this.cls = new Set(v.split(" ").filter(Boolean)); } + get classList() { const s = this.cls; return { add: (c) => s.add(c), remove: (c) => s.delete(c) }; } + append(...kids) { for (const k of kids) { k.parent = this; this.kids.push(k); } } + addEventListener(type, f) { (this.on[type] ??= []).push(f); } + fire(type, ev) { for (const f of this.on[type] ?? []) f(ev); } + contains(x) { for (let n = x; n; n = n.parent) if (n === this) return true; return false; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + closest() { for (let n = this; n; n = n.parent) if (n.tagName === "TR") return n; return null; } + querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); } + querySelector(sel) { const i = sel.match(/data-i="(-?\\d+)"/)[1]; return this.all().find((n) => n.dataset.i === i) ?? null; } + focus() { doc.activeElement = this; } + blur() { doc.activeElement = doc.body; } +} +const doc = new FakeEl("#document"); +doc.body = new FakeEl("body"); +doc.activeElement = doc.body; +doc.createElement = (t) => new FakeEl(t); +globalThis.document = doc; +const hovers = []; +function createButton(o) { + const b = new FakeEl("button"); + b.label = o.label; b.variant = o.variant; + b.addEventListener("click", o.onClick); + return b; +} +function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); } + +let user = {}; +let table, root, bar; +const draw = () => { + table = buildBill({ sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }); + if (root) root.isConnected = false; + if (root?.contains(doc.activeElement)) doc.activeElement = doc.body; // 지운 요소 초점 = body + root = new FakeEl("div"); + table.rows.forEach((r, i) => { + const tr = new FakeEl("tr"); tr.dataset.i = String(i); + const td = new FakeEl("td"); const input = new FakeEl("input"); + td.append(input); tr.append(td); root.append(tr); + }); + bar = levelBar(root, table); +}; +const at = (key) => table.rows.findIndex((r) => r.key === key); +const tr = (key) => root.kids[at(key)]; +const pick = (key) => root.fire("click", { target: tr(key).kids[0] }); +const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON"); +const off = () => buttons().map((b) => b.disabled); +const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]); +const key = (k, more = {}) => { + let stopped = false; + doc.fire("keydown", { key: k, shiftKey: false, altKey: false, ...more, preventDefault: () => (stopped = true) }); + return stopped; +}; +const out = {}; +draw(); +out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]); +out.guide = bar.kids.at(-1).textContent; +out.hover = hovers.length; +out.none = off(); +pick("earth:4"); +out.earth4 = off(); +out.focus = [tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")]), + pickedClass(table.rows[at("earth:5")])]; +buttons()[3].fire("click"); +out.down = order(); +user = {}; draw(); +out.kept = [off(), tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")])]; +root.focus(); +out.tabStop = key("Tab"); +out.tabNoop = order(); +out.shiftTab = [key("Tab", { shiftKey: true }), order()]; +user = {}; draw(); +pick("earth:5"); +root.focus(); +out.altUp = [key("ArrowUp", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +out.altDown = [key("ArrowDown", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = tr("earth:4").kids[0].kids[0]; +out.editing = [key("Tab", { shiftKey: true }), key("ArrowDown", { altKey: true }), order()]; +out.esc = [key("Escape"), doc.activeElement === root]; +out.afterEsc = [key("Tab", { shiftKey: true }), order().length > 0]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = new FakeEl("input"); +out.outside = [key("Tab", { shiftKey: true }), order()]; +root.focus(); +key("Escape"); +out.unpick = [off(), tr("earth:4").cls.has("is-focus"), key("Tab")]; +out.keyAct = [ + keyAct({ key: "Tab", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: true, altKey: false }, false), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: true }, false), + keyAct({ key: "ArrowDown", shiftKey: false, altKey: true }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false }, true), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false, ctrlKey: true }, false), +]; +console.log(JSON.stringify(out)); +""" + +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"] +IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓" + + +def _bar(): + body = BAR_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + body)) + + +def _keys(order): + return [k for k, _ in order] + + +def test_단추_넷_고른_줄_못_하면_흐림(): + out = _bar() + assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"], + ["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip + assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"] + assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히 + assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림 + # earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음) + assert out["earth4"] == [True, False, True, False] + assert out["focus"] == [True, "is-focus", None] + # [↓ 아래로] = 메뉴 「아래로 ↓」 와 같은 결과 + keys = _keys(out["down"]) + assert keys.index("earth:5") + 1 == keys.index("earth:4") + # 다시 그려도 고른 줄 · 단추 상태 남음 + assert out["kept"] == [[True, False, True, False], True, "is-focus"] + + +def test_키_셋_편집_중엔_안_먹음(): + out = _bar() + assert out["tabStop"] is True and out["tabNoop"] == [] # 못 하는 들여쓰기 = 바뀜 없음 + stopped, order = out["shiftTab"] + assert stopped and ("earth:4", "sec:1") in [tuple(x) for x in order] # 내어쓰기 + for name in ("altUp", "altDown"): + stopped, order = out[name] + keys = _keys(order) + assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name + # 글 칸 편집 중 = 키 안 먹음(기본 동작 그대로) · Esc = 편집 끝(표에 초점) → 키 먹음 + assert out["editing"] == [False, False, []] + assert out["esc"] == [False, True] + assert out["afterEsc"] == [True, True] + # 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작 + assert out["outside"] == [False, []] + assert out["unpick"] == [[True] * 4, False, False] + assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None] + + +def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + bill = '.b09-estimation [data-b09-table="bill"]' + # 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding + for lv in range(2, 7): + assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "") + pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);" + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad), + css, + ) + assert re.search( + re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{") + + r"\s*" + + re.escape(pad), + css, + ) + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{') + + r"\s*padding-left: 0;", + css, + ) + # 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만) + assert re.search( + re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", + css, + ) + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css + ) + assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음 + assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) + + +def test_셸_배선(): + src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src + assert "...(level ? [level] : []), table.root" in src + lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수