"""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