"""185-3 공용 호버 정보 양식(`ui_template_hover_info.ts`) — 지연 · 뒤집기 · 초점 · 한 번에 하나 · 토큰. Node 로 모듈을 그대로 돌림(작은 가짜 DOM · 타이머 손으로 돌림) · 자리 셈 `placeHoverInfo` 는 함수만. """ import json import re import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TS = ROOT / "config" / "node_modules" / "typescript" MOD = ROOT / "ui_template" / "ui_template_hover_info.ts" CSS = ROOT / "ui_template" / "ui_template_hover_info.css" PRELUDE = f""" const fs = require("fs"); const ts = require({json.dumps(str(TS))}); const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{ compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, }}).outputText; class Node {{}} class Element extends Node {{ constructor(tag, sel) {{ super(); this.tag = tag; this.sel = sel; this.parentElement = null; this.children = []; this.attrs = {{}}; this.on = {{}}; this.style = {{}}; this.hidden = false; this.isConnected = true; this.offsetWidth = 200; this.offsetHeight = 40; this.textContent = ""; this.rect = {{ left: 100, top: 100, right: 180, bottom: 120 }}; const set = new Set(); this.classList = {{ set, toggle: (c, on) => (on ? set.add(c) : set.delete(c)), contains: (c) => set.has(c) }}; }} appendChild(c) {{ c.parentElement = this; this.children.push(c); return c; }} contains(n) {{ for (let x = n; x; x = x.parentElement) if (x === this) return true; return false; }} closest(sel) {{ for (let x = this; x; x = x.parentElement) if (x.sel === sel) return x; return null; }} setAttribute(k, v) {{ this.attrs[k] = String(v); }} getAttribute(k) {{ return this.attrs[k] ?? null; }} removeAttribute(k) {{ delete this.attrs[k]; }} addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }} removeEventListener(t, f) {{ this.on[t] = (this.on[t] || []).filter((g) => g !== f); }} getBoundingClientRect() {{ return this.rect; }} fire(type, extra = {{}}) {{ const e = {{ type, target: this, ...extra }}; for (let x = this; x; x = x.parentElement) (x.on[type] || []).forEach((f) => f(e)); }} }} const document = new Element("#document"); document.body = document.appendChild(new Element("body")); document.createElement = (t) => new Element(t); const window = {{ innerWidth: 1000, innerHeight: 800 }}; let timers = []; let seq = 0; const fakeSet = (f, ms) => (timers.push({{ id: ++seq, f, ms }}), seq); const fakeClear = (id) => (timers = timers.filter((t) => t.id !== id)); const tick = () => {{ const now = timers; timers = []; now.forEach((t) => t.f()); return now.map((t) => t.ms); }}; const mod = {{ exports: {{}} }}; new Function("exports", "module", "require", "document", "window", "Element", "Node", "setTimeout", "clearTimeout", js)(mod.exports, mod, () => ({{}}), document, window, Element, Node, fakeSet, fakeClear); const H = mod.exports; const box = () => document.body.children.filter((c) => c.attrs.role === "tooltip"); const out = {{}}; """ def _node(body: str): script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));" got = subprocess.run( ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 ) assert got.returncode == 0, got.stderr return json.loads(got.stdout) def test_자리_아래가_기본_아래_모자라면_위로_뒤집기_좌우는_화면_안(): got = _node( """ const view = { width: 1000, height: 800 }; const size = { width: 200, height: 40 }; const at = (l, t, r, b) => H.placeHoverInfo({ left: l, top: t, right: r, bottom: b }, size, view); out.below = at(100, 100, 180, 120); out.bottom = at(100, 760, 180, 780); out.right = at(950, 100, 990, 120); out.left = at(-30, 100, 20, 120); out.tight = at(100, 10, 180, 790); """ ) assert got["below"] == {"x": 100, "y": 126, "above": False} assert got["bottom"] == {"x": 100, "y": 714, "above": True} # 760 - 6 - 40 assert got["right"]["x"] == 1000 - 6 - 200 and not got["right"]["above"] assert got["left"]["x"] == 6 assert got["tight"]["above"] is False # 위도 모자라면 아래 그대로 def test_마우스_짧은_지연_뒤_뜸_떠나면_닫힘_aria(): got = _node( """ const root = document.body.appendChild(new Element("table")); const cell = root.appendChild(new Element("td", "td")); const inner = cell.appendChild(new Element("span")); H.attachHoverInfo(root, (t) => (t === cell ? "물가정보 · 2026-07 · 670쪽" : null), "td"); inner.fire("mouseover"); out.beforeTick = box().length ? !box()[0].hidden : false; out.delays = tick(); out.text = box()[0].textContent; out.shown = !box()[0].hidden; out.aria = cell.attrs["aria-describedby"] === box()[0].id; out.pos = [box()[0].style.left, box()[0].style.top]; inner.fire("mouseout", { relatedTarget: cell }); // 칸 안에서 움직임 = 그대로 out.stay = !box()[0].hidden; cell.fire("mouseout", { relatedTarget: root }); out.gone = box()[0].hidden && !("aria-describedby" in cell.attrs); """ ) assert got["beforeTick"] is False and got["delays"] == [350] assert got["text"] == "물가정보 · 2026-07 · 670쪽" and got["shown"] and got["aria"] assert got["pos"] == ["100px", "126px"] assert got["stay"] and got["gone"] def test_키보드_초점이면_지연_없이_뜸_화면_끝이면_뒤집힘(): got = _node( """ const root = document.body.appendChild(new Element("div")); const btn = root.appendChild(new Element("button")); btn.rect = { left: 900, top: 770, right: 990, bottom: 790 }; H.attachHoverInfo(btn, "단추 설명"); btn.fire("focusin"); out.delays = tick(); const b = box()[0]; out.text = b.textContent; out.above = b.classList.contains("is-above"); out.pos = [b.style.left, b.style.top]; btn.fire("focusout", { relatedTarget: null }); out.gone = b.hidden; """ ) assert got["delays"] == [0] and got["text"] == "단추 설명" assert got["above"] and got["pos"] == ["794px", "724px"] assert got["gone"] def test_한_번에_하나_빈_글은_안_뜸_Esc_누름_떼기(): got = _node( """ const root = document.body.appendChild(new Element("div")); const a = root.appendChild(new Element("td", "td")); const b = root.appendChild(new Element("td", "td")); const c = root.appendChild(new Element("td", "td")); const stop = H.attachHoverInfo(root, (t) => (t === a ? "A" : t === b ? "B" : ""), "td"); a.fire("focusin"); tick(); b.fire("focusin"); tick(); out.count = box().length; out.text = box()[0].textContent; out.aOff = !("aria-describedby" in a.attrs) && "aria-describedby" in b.attrs; c.fire("focusin"); tick(); out.emptyHidden = box()[0].hidden; a.fire("focusin"); tick(); document.fire("keydown", { key: "Escape" }); out.esc = box()[0].hidden; a.fire("focusin"); tick(); a.fire("focusout", { relatedTarget: null }); a.fire("mouseover"); tick(); document.fire("pointerdown"); out.press = box()[0].hidden; stop(); a.fire("mouseover"); out.afterStop = timers.length; """ ) assert got["count"] == 1 and got["text"] == "B" and got["aOff"] assert got["emptyHidden"] and got["esc"] and got["press"] assert got["afterStop"] == 0 def test_모양은_테마_토큰_밝음_어두움_같이_마우스_안_막음(): css = CSS.read_text(encoding="utf-8") rule = css[css.index(".ui-hover-info {") :] assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule) # 색 글자 박지 않음 for token in ("--color-surface-raised", "--color-border", "--color-text-body", "--z-toast"): assert f"var({token})" in rule assert "pointer-events: none" in rule and "position: fixed" in rule theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") dark = theme[theme.index(':root[data-theme="dark"]') :] assert "--color-canvas:" in dark and "--color-mist:" in dark # 어두움 = 토큰 바탕이 바뀜 src = MOD.read_text(encoding="utf-8") assert ( 'import "./ui_template_hover_info.css";' in src and 'setAttribute("role", "tooltip")' in src )