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

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts
This commit is contained in:
2026-10-10 19:12:04 +09:00
23 changed files with 316 additions and 79 deletions
@@ -0,0 +1,72 @@
/* 236-1 — 표 아래 안내 글이 한 줄일 때 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)이
* 창 크기마다 문서 맨 아래 = 창 끝(페이지 스크롤 0)이 되는지 단위검증 헬퍼. JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const ONE_LINE = 18; // 안내 글 한 줄(caption 글꼴) 높이
const win = { innerHeight: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} };
const observers = [];
class RO {
constructor(fn) {
observers.push(fn);
}
observe() {}
disconnect() {}
}
const el = (css = {}, height = 0) => ({
css,
height,
top: 0,
parentElement: null,
nextElementSibling: null,
isConnected: true,
style: {},
getBoundingClientRect() {
return { top: this.top, height: this.height };
},
});
const sty = (n) => ({
rowGap: n.css.rowGap ?? "normal",
paddingBottom: n.css.paddingBottom ?? "0px",
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
marginBottom: n.css.marginBottom ?? "0px",
});
const box = { exports: {} };
new Function(
"exports",
"module",
"require",
"window",
"document",
"getComputedStyle",
"ResizeObserver",
js,
)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO);
const out = [];
for (const h of [1080, 768, 600]) {
win.innerHeight = h;
observers.length = 0;
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [표 칸][안내 한 줄]
const body = el({ paddingBottom: "8px" });
const card = el({ paddingBottom: "12px", rowGap: "8px" });
const root = el();
const hint = el({}, ONE_LINE);
card.parentElement = body;
root.parentElement = card;
hint.parentElement = card;
root.nextElementSibling = hint;
root.top = 200;
box.exports.fillViewport(root);
observers.forEach((fn) => fn());
const grow = parseFloat(root.style.maxHeight);
const below = box.exports.spaceBelow(root);
out.push({ viewport: h, bottom: root.top + grow + below, grow, below });
}
console.log(JSON.stringify(out));
@@ -68,9 +68,7 @@ def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch):
monkeypatch.setattr(router, "structure_summary", summary)
monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"])
got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({})))
assert got["notes"] == [
"2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡"
]
assert got["notes"] == ["2공구 열 맞춤 없음 2종 — 이형철근 D13 ton · 토목용부직포 ㎡"]
SC = {"층": "project", "주인": None, "키": "SC-000001"}
@@ -14,8 +14,8 @@ SPOTS = {
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'],
"ui_template/ui_template_locale_b2.ts": [
"수량 원천: 수기 입력(직접비)",
"수량 원천: 수기 입력 공종",
@@ -27,4 +27,4 @@ def test_견적은_단가_견적서_그_밖은_단가_하나():
def test_견적_면_수기_칸_없앰():
body = _compare()
assert 'key === "g:견적"' not in body and "quote_page" not in body
assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body
assert "견적 단가는 수기 입력" in body and "견적 단가 · 면" not in body
+1 -1
View File
@@ -279,7 +279,7 @@ def test_표_칸_강조_번호_연결_칸():
assert out["cls"] == ["is-edited", None, None]
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
assert out["edit"] == [True, False, True, True, False]
assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"]
assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"]
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
@@ -0,0 +1,160 @@
"""236-1 B08 · B09 표 아래 안내 글 — 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0.
- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음
- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버)
- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표)
- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B08 = ROOT / "B08_Quantity"
B09 = ROOT / "B09_Estimation"
STRING = re.compile(r'"([^"\\\n]*)"|`([^`\n]*)`')
FORBID = ["ROUND", " = ", "÷", "×", "Σ", "M01", "역전개", "원문"]
CHAPTER = re.compile(r"\(\d{2,3}-\d\)|\b\d{1,3}-\d-\d\b|\b\d{2,3}-\d\b")
MAX_PIECE = 70
def _read(path: Path) -> str:
return path.read_text("utf-8")
def _pieces(expr: str) -> list[str]:
out = []
for m in STRING.finditer(expr):
text = m.group(1) if m.group(1) is not None else m.group(2)
out.append(re.sub(r"\$\{[^}]*\}", "", text).strip())
return [p for p in out if p]
def _b09_hints() -> list[tuple[str, str]]:
found: list[tuple[str, str]] = []
for path in sorted(B09.glob("B09_Estimation_UI_Tabs_*.ts")):
src = _read(path)
for m in re.finditer(r"\bhint:\s*(.*?),\n\s+\w+[:(]", src, re.S):
found += [(path.name, p) for p in _pieces(m.group(1))]
for m in re.finditer(
r"^const (?:LINK_HINT|BLANK|HINT|EXPENSE_NONE|GUIDE)\s*=\s*(.*?);\n", src, re.S | re.M
):
found += [(path.name, p) for p in _pieces(m.group(1))]
return found
def _b08_hints() -> list[tuple[str, str]]:
found: list[tuple[str, str]] = []
for name in (
"B08_Quantity_UI_Earthwork.ts",
"B08_Quantity_UI_Earthwork_Sheets.ts",
"B08_Quantity_UI_Material.ts",
):
src = _read(B08 / name)
for m in re.finditer(r"footNote\(((?:\s|\+|\"[^\"\n]*\"|`[^`\n]*`)+)\)", src):
found += [(name, p) for p in _pieces(m.group(1))]
for m in re.finditer(r"^const HINTS[^=]*=\s*\{(.*?)\n\};", src, re.S | re.M):
found += [(name, p) for p in _pieces(m.group(1))]
for m in re.finditer(r"^const HIDDEN\s*=\s*(.*?);\n", src, re.S | re.M):
found += [(name, p) for p in _pieces(m.group(1))]
return found
def _server_notes() -> list[tuple[str, str]]:
found: list[tuple[str, str]] = []
for name in ("Router_Summary", "Router_Material"):
src = _read(B08 / f"B08_Quantity_{name}.py")
for m in re.finditer(r"notes\.append\(\s*(.*?)\)\n", src, re.S):
found += [(name, p) for p in _pieces(m.group(1).replace('f"', '"'))]
sangeun = _read(B09 / "B09_Estimation_Sangeun.py")
for m in re.finditer(r'^(?:NO_SOURCE|EMPTY) = "([^"]*)"', sangeun, re.M):
found.append(("Sangeun", m.group(1)))
return found
def _all() -> list[tuple[str, str]]:
return _b09_hints() + _b08_hints() + _server_notes()
def test_안내_글_모음이_비지_않음():
assert len(_b09_hints()) >= 20
assert len(_b08_hints()) >= 4
assert len(_server_notes()) >= 7
def test_제작_셈_설명_낱말_없음():
bad = []
for where, text in _all():
bad += [(where, token, text) for token in FORBID if token in text]
if CHAPTER.search(text):
bad.append((where, "장 번호", text))
assert not bad, bad
def test_안내_글_조각_짧음():
long = [(w, t) for w, t in _all() if len(t) > MAX_PIECE]
assert not long, long
# ── 늘 한 줄 ───────────────────────────────────────────────────────────────
def _block(css: str, selector: str) -> str:
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_B09_안내_글_한_줄():
css = _read(B09 / "B09_Estimation_UI_Page.css")
block = _block(css, ".b09-estimation__line-hint")
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄
assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts")
level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts")
assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level
def test_B08_안내_줄_한_줄():
css = _read(B08 / "B08_Quantity_UI_Page.css")
block = _block(css, ".b08-quantity__foot")
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
assert "display" not in block # hidden 속성(콘크리트 빈 까닭)을 덮지 않음
foot = _read(B08 / "B08_Quantity_UI_Foot.ts")
assert "b08-quantity__foot" in foot and "attachHoverInfo" in foot
for name in ("Earthwork", "Earthwork_Sheets", "Material", "Summary"):
assert "footNote" in _read(B08 / f"B08_Quantity_UI_{name}.ts"), name
def test_B08_표_아래_안내는_footNote만():
material = _read(B08 / "B08_Quantity_UI_Material.ts")
assert "note(BASIS)" not in material # 「구조물 수량 기준 …」 제작 설명 줄 없음
assert "const excel = footNote(" in material
assert "const concreteNote = footNote(" in material
summary = _read(B08 / "B08_Quantity_UI_Summary.ts")
assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임
sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts")
assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음
# ── 창 끝까지 셈 ────────────────────────────────────────────────────────────
def test_창_크기마다_한_줄_안내_뒤_창_끝():
proc = subprocess.run(
["node", str(ROOT / "resources" / "tester" / "helper_236_1_fill_sizes.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
rows = json.loads(proc.stdout)
assert [r["viewport"] for r in rows] == [1080, 768, 600]
for r in rows:
assert r["below"] == 18 + 8 + 12 + 8 # 안내 한 줄 + 줄 사이 + 카드 + 본문 여백
assert r["bottom"] == r["viewport"], r # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0