"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판. - 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점) - 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘 - 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감) """ 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) DOM_JS = """ const docOn = {}; class El { constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {}, className: "", hidden: false, textContent: "", attrs: {} }); } get classList() { const el = this; const list = () => el.className.split(" ").filter(Boolean); return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); }, remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")), contains: (c) => list().includes(c) }; } get ownerDocument() { return { defaultView: globalThis.view }; } get firstElementChild() { return this.kids[0] ?? null; } get lastElementChild() { return this.kids.at(-1) ?? null; } setAttribute(k, v) { this.attrs[k] = v; } append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } } replaceChildren() { this.kids = []; } remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); } contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; } addEventListener(t, f) { (this.on[t] ??= []).push(f); } fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {}, stopPropagation() {}, ...ev }); } focus() { doc.activeElement = this; } all() { return this.kids.flatMap((k) => [k, ...k.all()]); } match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c)) : this.tag === sel; } querySelectorAll(sel) { let found = [this]; for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part))); return found; } querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; } getBoundingClientRect() { if (this.style.left !== undefined) { const side = this.classList.contains("ui-ctx-menu__side"); const left = parseFloat(this.style.left), top = parseFloat(this.style.top); const width = side ? 150 : 160; const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length; return { left, top, width, height, right: left + width, bottom: top + height }; } const box = this.parent.getBoundingClientRect(); const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this); return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 }; } } const doc = { activeElement: null, createElement: (t) => new El(t), addEventListener: (t, f) => (docOn[t] ??= []).push(f) }; globalThis.document = doc; globalThis.Node = El; globalThis.view = { innerWidth: 1000, innerHeight: 800 }; globalThis.window = globalThis.view; """ SCENE_JS = """ const log = []; const menu = createMapContextMenu("b09-estimation"); const items = [["A", () => log.push("A")], { label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-", ["B", () => log.push("B")]]; const el = menu.element; const btns = () => el.kids.filter((k) => k.tag === "button"); const side = () => el.querySelector(".ui-ctx-menu__side"); const sideText = () => side()?.kids.map((k) => k.textContent) ?? null; const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position]; const out = {}; menu.open(100, 100, items); const head = btns()[1]; out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]]; head.fire("pointerenter"); out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className]; out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top; out.contains = menu.contains(side().kids[0]); btns()[0].fire("pointerenter"); out.leave = [sideText(), head.classList.contains("is-open")]; head.fire("click"); out.click = [sideText(), doc.activeElement?.textContent]; side().fire("keydown", { key: "ArrowLeft" }); out.left = [sideText(), doc.activeElement === head]; head.fire("keydown", { key: "ArrowRight" }); out.right = [sideText(), doc.activeElement?.textContent]; side().kids[1].fire("click"); out.pick = [el.hidden, el.kids.length, [...log]]; // 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽 view.innerWidth = 400; menu.open(300, 100, items); btns()[1].fire("pointerenter"); out.edge = [parseFloat(el.style.left), at()[0]]; // 아래 끝 — 옆 판 올림 view.innerWidth = 1000; view.innerHeight = 100; menu.open(100, 10, items); btns()[1].fire("pointerenter"); out.bottom = at()[1]; view.innerHeight = 800; // Esc · 바깥 누름 = 모두 닫힘 menu.open(100, 100, items); btns()[1].fire("pointerenter"); for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} }); out.esc = [el.hidden, side()]; menu.open(100, 100, items); btns()[1].fire("pointerenter"); const inside = side().kids[0]; for (const f of docOn.pointerdown) f({ target: inside }); out.insideDown = el.hidden; for (const f of docOn.pointerdown) f({ target: new El("div") }); out.outsideDown = el.hidden; 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 _src() -> str: return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8") def test_옆_판_열기_닫기_자리(): out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS)) # 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음) assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"] # 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side" assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names] assert out["firstRow"] is True and out["contains"] is True assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘 assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점 assert out["left"] == [None, True] # ← = 닫고 머리 줄로 assert out["right"] == [["x", "y"], "x"] # → = 열기 assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작 # 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150) assert out["edge"] == [236, 86] # 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4 assert out["bottom"] == 100 - 4 - 68 assert out["esc"] == [True, None] assert out["insideDown"] is False and out["outsideDown"] is True def test_옛_아코디언_없음_공용_꼴(): src = _src() assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8") assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css # B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로) edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8") assert "parts[1].push(...picks, ...more);" in edit # 251-6 단가연결 = 모달 하나(하위 메뉴 없음)