diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 7147fe004..fbc1cb7a2 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -20,6 +20,7 @@ import { } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createDataTable, type DataTable } from "@ui/ui_template_data_table"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -227,16 +228,16 @@ export function renderB09Estimation(root: HTMLElement): void { const box = cell.getBoundingClientRect(); openMenuAt(box.left, box.bottom, items); }); - // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title + // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3) if (spec.tip) - made.root.addEventListener("mouseover", (event) => { - const cell = (event.target as Element).closest("tr[data-i] td"); - const row = cell?.parentElement; - const column = cell ? spec.columns[cell.cellIndex] : undefined; - if (!cell || !row || !column) return; - const tip = spec.tip?.(Number(row.dataset.i), column.key); - if (tip) cell.title = tip; - }); + attachHoverInfo( + made.root, + (cell) => { + const column = spec.columns[(cell as HTMLTableCellElement).cellIndex]; + return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key); + }, + "tr[data-i] td", + ); return made; }; const hint = spec.hint ? note(spec.hint) : null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 76d071d45..e16abf10f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -9,7 +9,8 @@ * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) - * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) + * 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶) + * · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ @@ -125,10 +126,33 @@ function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMen ]; } +/** 출처 단가 칸 호버 글(185-4) — M01 면수 「670(26.7)」 · 유가 기준일 「2026-08-14」 를 뜻으로 풂 · + * 값 없는(0 · 빈칸) 칸 = 없음 · 값 · 저장 꼴은 그대로 */ +export function sourceInfo(r: Row, s: string, files = 0): string | null { + if (!(Number(r.prices?.[s]) > 0)) return null; + const page = String(r.pages?.[s] ?? "").trim(); + if (s === "관급") return "관급 · 조달청 계약 단가"; + if (s === "견적") return `견적 · 수기 입력${files ? ` · 견적서 ${files} 파일` : ""}`; + if (s === "유가") { + const from = String(r.source ?? ""); + if (!page) return "유가"; + if (from.includes("오피넷")) return `유가 · 오피넷 · ${page} 수집`; + if (from.includes("한전")) return `전기요금 · 한전 · ${page} 시행`; + return `유가 · ${page} 기준`; + } + // 면수 = [부(부록) · 하(하권) · n권] 쪽(연.월) — 예 「부536(26.7)」 = 2026-07 호 부록 536쪽 + const m = /^(부|하|\d+권)?(\d+)\((\d{2})\.(\d{1,2})\)$/.exec(page); + if (!m) return page ? `${s} · ${page}` : `${s} · 면수 없음`; + const book = m[1] === "부" ? "부록 " : m[1] === "하" ? "하권 " : m[1] ? `${m[1]} ` : ""; + return `${s} · 20${m[3]}-${m[4].padStart(2, "0")} · ${book}${m[2]}쪽`; +} + export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { const rows: Row[] = sheets.compare || []; const out: DataRow[] = rows.map((r) => { const files: string[] = (user.materials || {})[r.key]?.files || []; + // 185-5 견적서 글자 = 견적 단가 수기 줄만(올린 파일이 남은 줄도 · 나머지 빈칸) + const quoted = r.prices["견적"] != null || files.length > 0; const row: DataRow = { no: r.no, key: r.key, @@ -139,12 +163,9 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { applied: r.applied, applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""), supply: r.supply ?? "", - files: files.length ? `${files.length} 파일` : "첨부", + files: !quoted ? "" : files.length ? `${files.length} 파일` : "첨부", }; - for (const s of SOURCES) { - row[`p:${s}`] = r.prices[s]; - row[`g:${s}`] = r.pages[s] ?? ""; - } + for (const s of SOURCES) row[`p:${s}`] = r.prices[s]; return row; }); const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) }); @@ -160,11 +181,12 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { text("name", "품명", 180), text("spec", "규격", 140), text("unit", "단위", 56), - // 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값) - ...SOURCES.flatMap((s) => [ - money(`p:${s}`, "단가", [s]), - s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]), - ]), + // 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서 + ...SOURCES.flatMap((s) => + s === "견적" + ? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])] + : [money(`p:${s}`, "단가", [s])], + ), money("lowest", "최저"), money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), @@ -172,7 +194,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)", + tip: (i, key) => + key.startsWith("p:") && rows[i] + ? sourceInfo( + rows[i], + key.slice(2), + ((user.materials || {})[rows[i].key]?.files || []).length, + ) + : null, editable: (_i, key) => key === "p:견적", onEdit: (i, key, value) => { const row = out[i]; @@ -201,7 +231,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { const r = rows[i]; if (!row || !r) return; const cell = mine(String(row.key)); - if (key === "files") { + if (key === "files" && row.files) { const label = `${r.name} ${r.spec}`.trim(); return openQuotes(r.key, label, (names) => { const now = mine(r.key); diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py index 3040fe43a..21fd23dcb 100644 --- a/resources/tester/test_183_2_b09_compare_head.py +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -1,4 +1,4 @@ -"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래. +"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 견적서 = 견적 아래 · 다른 출처 「출처」 열은 185-4 에서 호버로. 소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4). """ @@ -14,11 +14,12 @@ def _compare() -> str: return SRC[start : SRC.index("export const buildMaterial")] -def test_견적은_단가_견적서_그_밖은_단가_출처(): +def test_견적은_단가_견적서_그_밖은_단가_하나(): + # 185-4 — 출처 열 없앰(출처 뜻 = 단가 칸 호버 · test_185_4) body = _compare() - assert ( - 's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body - ) + assert '[money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]' in body + assert ': [money(`p:${s}`, "단가", [s])],' in body + assert "g:${s}" not in body and '"출처", 64' not in body assert '"면"' not in body # 면 머리 없음 assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움 diff --git a/resources/tester/test_185_3_hover_info.py b/resources/tester/test_185_3_hover_info.py new file mode 100644 index 000000000..45e900e4c --- /dev/null +++ b/resources/tester/test_185_3_hover_info.py @@ -0,0 +1,187 @@ +"""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 + ) diff --git a/resources/tester/test_185_4_b09_compare_source_hover.py b/resources/tester/test_185_4_b09_compare_source_hover.py new file mode 100644 index 000000000..954d42bd1 --- /dev/null +++ b/resources/tester/test_185_4_b09_compare_source_hover.py @@ -0,0 +1,158 @@ +"""185-4 · 185-5 자재단가대비표 — 「출처」 열 없앰 · 출처 뜻 = 단가 칸 호버 · 견적서 글자 = 수기 견적 줄만. + +Node 로 `buildCompare` 를 그대로 돌림(이웃 모듈은 작은 대역) · 값 · 저장 꼴(`user.materials`)은 그대로. +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" +BASE = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts" + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = ts.transpileModule(fs.readFileSync({json.dumps(str(BASE))}, "utf8"), {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적"]; +const col = (kind) => (key, label, a, b) => + ({{ key, label, kind, group: Array.isArray(a) ? a : Array.isArray(b) ? b : undefined }}); +const opened = []; +const stubs = {{ + "./B09_Estimation_Calc_Base": {{ SOURCES }}, + "./B09_Estimation_UI_Tabs_Base_Quote": {{ openQuotes: (key) => opened.push(key) }}, + "./B09_Estimation_UI_Tabs": {{ + money: col("number"), qty: col("number"), text: col(undefined), + parseNumber: (t) => (t === "" ? null : Number(t)), + }}, +}}; +const mod = {{ exports: {{}} }}; +new Function("exports", "module", "require", js)(mod.exports, mod, (p) => stubs[p]); +const B = mod.exports; +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) + + +ROWS = """ +const blank = Object.fromEntries(SOURCES.map((s) => [s, null])); +const compare = [ + { key: "MT1", name: "백호우티스", spec: "0.2㎡", unit: "개", applied_from: "유통물가", + prices: { ...blank, 유통물가: 5880, 물가정보: 9200, 관급: 5000 }, + pages: { ...blank, 유통물가: "1094(26.7)", 물가정보: "부536(26.7)" } }, + { key: "MO2", name: "자동차용경유", spec: "전국평균", unit: "L", applied_from: "유가", + source: "유가전력 오피넷유가 D047", prices: { ...blank, 유가: 1846.39 }, pages: { ...blank, 유가: "2026-08-14" } }, + { key: "B08:1", name: "견적 자재", spec: "", unit: "개", applied_from: "견적", + prices: { ...blank, 견적: 12000 }, pages: { ...blank } }, + { key: "B08:2", name: "파일만 남은 줄", spec: "", unit: "개", applied_from: null, + prices: { ...blank }, pages: { ...blank } }, +]; +const user = { materials: { "B08:1": { quote: 12000, files: ["a.pdf", "b.pdf"] }, "B08:2": { files: ["c.pdf"] } } }; +let saved = null; +const table = B.buildCompare({ sheets: { compare }, user, setUser: (u) => (saved = u) }); +""" + + +def test_출처_열_없음_출처_묶음은_단가_하나_견적만_견적서(): + got = _node( + ROWS + + """ +out.keys = table.columns.map((c) => c.key); +out.labels = table.columns.filter((c) => c.group).map((c) => `${c.group[0]}/${c.label}`); +out.rowKeys = Object.keys(table.rows[0]); +""" + ) + assert not [k for k in got["keys"] if k.startswith("g:")] + assert got["labels"][:7] == [ + "관급/단가", + "물가정보/단가", + "물가자료/단가", + "유통물가/단가", + "거래가격/단가", + "유가/단가", + "견적/단가", + ] + assert got["labels"][7] == "견적/견적서" and got["labels"].count("견적/견적서") == 1 + assert not [k for k in got["rowKeys"] if k.startswith("g:")] + assert "applied_from" in got["keys"] # 적용 묶음의 「출처」(고른 출처 이름)는 그대로 + + +def test_출처_단가_칸_호버_글_숫자_뜻을_풀어_짧게(): + got = _node( + ROWS + + """ +const tip = (i, k) => table.tip(i, k); +out.tips = [tip(0, "p:유통물가"), tip(0, "p:물가정보"), tip(0, "p:관급"), tip(0, "p:거래가격"), + tip(1, "p:유가"), tip(2, "p:견적"), tip(0, "name"), tip(0, "applied")]; +const r = (s, page) => ({ prices: { [s]: 100 }, pages: { [s]: page } }); +out.pages = [ + B.sourceInfo(r("물가자료", "하38(26.7)"), "물가자료"), + B.sourceInfo(r("거래가격", "1권413(17.4)"), "거래가격"), + B.sourceInfo(r("물가정보", "102(25.12)"), "물가정보"), + B.sourceInfo(r("물가정보", "536(26적정)"), "물가정보"), + B.sourceInfo(r("물가정보", null), "물가정보"), + B.sourceInfo({ prices: { 물가정보: 0 }, pages: { 물가정보: "1(26.7)" } }, "물가정보"), + B.sourceInfo({ ...r("유가", "2025-01-01"), source: "유가전력 한전 전기요금표" }, "유가"), + B.sourceInfo(r("견적", null), "견적"), +]; +""" + ) + assert got["tips"] == [ + "유통물가 · 2026-07 · 1094쪽", + "물가정보 · 2026-07 · 부록 536쪽", + "관급 · 조달청 계약 단가", + None, # 값 없는 칸 = 안 뜸 + "유가 · 오피넷 · 2026-08-14 수집", + "견적 · 수기 입력 · 견적서 2 파일", + None, + None, + ] + assert got["pages"] == [ + "물가자료 · 2026-07 · 하권 38쪽", + "거래가격 · 2017-04 · 1권 413쪽", + "물가정보 · 2025-12 · 102쪽", + "물가정보 · 536(26적정)", # 못 푸는 꼴 = 원문 그대로 + "물가정보 · 면수 없음", + None, + "전기요금 · 한전 · 2025-01-01 시행", + "견적 · 수기 입력", + ] + + +def test_견적서_글자는_수기_견적_줄만_빈칸은_안_엶_값_저장_꼴_그대로(): + got = _node( + ROWS + + """ +out.files = table.rows.map((r) => r.files); +out.prices = table.rows.map((r) => r["p:물가정보"]); +table.onCell(0, "files"); +table.onCell(1, "files"); +table.onCell(2, "files"); +table.onCell(3, "files"); +out.opened = opened; +table.onEdit(1, "p:견적", "3000"); +out.saved = saved.materials; +""" + ) + assert got["files"] == ["", "", "2 파일", "1 파일"] # 파일이 남은 줄은 안 숨김 + assert got["prices"] == [9200, None, None, None] + assert got["opened"] == ["B08:1", "B08:2"] + assert got["saved"]["MO2"] == {"quote": 3000} + assert got["saved"]["B08:1"] == {"quote": 12000, "files": ["a.pdf", "b.pdf"]} + + +def test_Shell_칸_풍선은_공용_호버_정보(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text(encoding="utf-8") + assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in shell + assert '"tr[data-i] td",' in shell and "cell.title = tip" not in shell diff --git a/ui_template/ui_template_hover_info.css b/ui_template/ui_template_hover_info.css new file mode 100644 index 000000000..ac71c5d76 --- /dev/null +++ b/ui_template/ui_template_hover_info.css @@ -0,0 +1,25 @@ +/* ============================================================================= + * 호버 정보 상자 공용 모양(`ui_template_hover_info.ts` · PLAN 185-3) — 화면에 하나 · fixed · + * 색 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음 + * ========================================================================== */ + +.ui-hover-info { + position: fixed; + z-index: var(--z-toast); + max-width: min(320px, calc(100vw - 12px)); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); + color: var(--color-text-body); + font-size: 12px; + line-height: 1.4; + white-space: pre-line; + overflow-wrap: anywhere; + pointer-events: none; +} + +.ui-hover-info[hidden] { + display: none; +} diff --git a/ui_template/ui_template_hover_info.ts b/ui_template/ui_template_hover_info.ts new file mode 100644 index 000000000..8080868ce --- /dev/null +++ b/ui_template/ui_template_hover_info.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(PLAN 185-3) + * + * attachHoverInfo(root, text, selector?) — root 안 selector 에 맞는 것(없으면 root 자신)에 + * 마우스(짧은 지연) · 키보드 초점(바로)이 오면 상자 · 떠나면 · 누름 · Esc · 스크롤이면 닫힘 · 돌려준 함수 = 떼기 + * text = 글 하나 또는 (대상) → 글(빈 값 = 안 띄움) · 표 칸 · 단추 어디나 같은 한 벌 + * 상자 = 화면에 하나(새로 뜨면 앞 것 갈음) · 대상 아래가 기본 · 아래가 모자라면 위로 뒤집고 + * 좌우는 화면 안으로 당김(`placeHoverInfo`) · 색 = 테마 토큰(밝음 · 어두움 같이) + * 옛 title 풍선은 그대로 둠 — 새 자리부터 이 양식(B09 칸 풍선 `tip` 이 첫 사용처) + * ========================================================================== */ + +import "./ui_template_hover_info.css"; + +export type HoverText = string | ((target: HTMLElement) => string | null | undefined); + +/** 뜨기까지 지연(ms) — 지나가는 마우스엔 안 뜸 */ +export const HOVER_DELAY_MS = 350; +/** 대상과 상자 사이 · 화면 끝 여백(px) */ +const GAP = 6; + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** 상자 자리 — 대상 아래(왼쪽 맞춤)가 기본 · 아래가 모자라고 위가 넉넉하면 위로 · 좌우 화면 안 */ +export function placeHoverInfo( + anchor: Box, + size: { width: number; height: number }, + view: { width: number; height: number }, +): { x: number; y: number; above: boolean } { + const below = anchor.bottom + GAP; + const above = below + size.height > view.height - GAP && anchor.top - GAP - size.height >= GAP; + const y = above ? anchor.top - GAP - size.height : below; + const x = Math.max(GAP, Math.min(anchor.left, view.width - GAP - size.width)); + return { x, y, above }; +} + +let box: HTMLDivElement | null = null; +let owner: HTMLElement | null = null; +let timer: ReturnType | null = null; +/** 지연 중인 대상 — 같은 대상 안에서 움직여도 지연을 새로 안 셈 */ +let pending: HTMLElement | null = null; +let globalBound = false; + +function shared(): HTMLDivElement { + if (box?.isConnected) return box; + box = document.createElement("div"); + box.className = "ui-hover-info"; + box.id = "ui-hover-info"; + box.setAttribute("role", "tooltip"); + box.hidden = true; + document.body.appendChild(box); + return box; +} + +/** 떠 있는 상자를 닫음(대상이 주면 그 대상 것만) */ +export function hideHoverInfo(target?: HTMLElement): void { + if (target && target !== owner) return; + if (timer) clearTimeout(timer); + timer = null; + pending = null; + owner?.removeAttribute("aria-describedby"); + owner = null; + if (box) box.hidden = true; +} + +function show(target: HTMLElement, text: string): void { + const el = shared(); + owner?.removeAttribute("aria-describedby"); + owner = target; + el.textContent = text; + el.hidden = false; + const r = target.getBoundingClientRect(); + const at = placeHoverInfo( + r, + { width: el.offsetWidth, height: el.offsetHeight }, + { width: window.innerWidth, height: window.innerHeight }, + ); + el.style.left = `${at.x}px`; + el.style.top = `${at.y}px`; + el.classList.toggle("is-above", at.above); + target.setAttribute("aria-describedby", el.id); +} + +function bindGlobal(): void { + if (globalBound) return; + globalBound = true; + document.addEventListener("keydown", (e) => e.key === "Escape" && hideHoverInfo()); + // 누름 = 닫음(누름 뒤 다시 그려져 대상이 사라져도 상자가 안 남음) + document.addEventListener("pointerdown", () => hideHoverInfo(), true); + document.addEventListener("scroll", () => hideHoverInfo(), true); +} + +export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: string): () => void { + bindGlobal(); + const find = (node: EventTarget | null): HTMLElement | null => { + if (!(node instanceof Element)) return null; + const hit = selector ? node.closest(selector) : root; + return hit && root.contains(hit) ? hit : null; + }; + const enter = (event: Event, delay: number): void => { + const target = find(event.target); + if (!target || target === owner || (timer && target === pending)) return; + if (timer) clearTimeout(timer); + pending = target; + timer = setTimeout(() => { + timer = null; + const value = typeof text === "function" ? text(target) : text; + if (value) show(target, value); + else hideHoverInfo(); + }, delay); + }; + const leave = (event: Event): void => { + const target = find(event.target); + const next = (event as MouseEvent | FocusEvent).relatedTarget; + if (!target || (next instanceof Node && target.contains(next))) return; + if (target === owner || target === pending) hideHoverInfo(); + }; + const over = (e: Event): void => enter(e, HOVER_DELAY_MS); + const focus = (e: Event): void => enter(e, 0); + root.addEventListener("mouseover", over); + root.addEventListener("mouseout", leave); + root.addEventListener("focusin", focus); + root.addEventListener("focusout", leave); + return () => { + root.removeEventListener("mouseover", over); + root.removeEventListener("mouseout", leave); + root.removeEventListener("focusin", focus); + root.removeEventListener("focusout", leave); + if (owner && root.contains(owner)) hideHoverInfo(); + }; +}