Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 13:00:00 +09:00
7 changed files with 564 additions and 27 deletions
@@ -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 # 맨 끝 견적서 열 지움
+187
View File
@@ -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
)
@@ -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