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

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Shell.ts
This commit is contained in:
2026-10-10 14:30:04 +09:00
17 changed files with 377 additions and 100 deletions
+2 -2
View File
@@ -152,13 +152,13 @@ export function note(text: string): HTMLElement {
return p;
}
/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */
/** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */
export function tableCard(
info: HTMLElement[],
table: HTMLElement,
after: HTMLElement[] = [],
): HTMLElement {
return createCard({ body: [...info, table, ...after] });
return createCard({ body: [table, ...after, ...info] });
}
function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
+2 -2
View File
@@ -356,7 +356,7 @@ export async function renderMaterialSheet(
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
concreteNote.dataset.b08MaterialConcreteWhy = "";
if (sheet === "concrete") notes.append(concreteNote);
toolbar.append(notes, save);
toolbar.append(save);
let built = BUILD[sheet](sheets, overrides);
const table = createDataTable({
@@ -395,7 +395,7 @@ export async function renderMaterialSheet(
concreteNote.hidden = !concreteNote.textContent;
};
redraw();
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래
onOptionsChange(host, () => {
sheets = calc();
redraw();
+1 -1
View File
@@ -28,7 +28,7 @@
.b08-material__toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: flex-end;
gap: var(--spacing-16);
margin-bottom: var(--spacing-8);
}
+1 -2
View File
@@ -281,8 +281,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
)
: note(L("B08_Quantity_Summary_NoStation"));
let table = draw();
parts.push(table);
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
host.replaceChildren(createCard({ body: [table, ...parts] })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) · 안내 · 경고 = 표 아래(202-1)
onOptionsChange(host, () => {
const next = draw();
table.replaceWith(next);
+12 -12
View File
@@ -36,30 +36,30 @@
overflow-wrap: anywhere;
}
/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */
.b09-estimation__bar {
/* 202-1 보조 탭 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 보조 줄에서도 보임 · 단추 · 글 한 줄 */
.b09-estimation__tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex: none;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
/* 185-2 보기 단추 글 한 줄 */
.b09-estimation__bar .ui-btn {
.b09-estimation__tools .ui-btn,
.b09-estimation__tools .ui-group-cards__label {
flex: none;
white-space: nowrap;
}
/* 200-1 묶음 탭 도구 줄 — 안내 글 = 남는 자리 한 줄 · 넘치면 줄임표(전문 = 공용 호버) */
.b09-estimation__bar {
flex-wrap: nowrap;
align-items: center;
/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */
.b09-estimation .ui-data-table + .ui-general-block__empty,
.b09-estimation .ui-group-cards + .ui-general-block__empty {
margin: var(--spacing-8) 0 0;
}
.b09-estimation__line-hint {
flex: 1 1 0;
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+13 -3
View File
@@ -183,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void {
const show = (tab: TabId, focus?: string): void => {
current = tab;
bars.draw(tab);
bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1)
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const build = BUILDERS[tab];
@@ -245,14 +246,23 @@ export function renderB09Estimation(root: HTMLElement): void {
if (isGroupTab(tab)) {
const redraw = (): void => show(tab);
return void panel.replaceChildren(
...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }),
...renderGroupPanel({
tab,
spec,
hint,
focus,
goto,
makeTable,
redraw,
setTools: bars.setTools,
}),
);
}
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
const level = bill ? levelBar(table.root, bill) : null;
panel.replaceChildren(...(hint ? [hint] : []), ...(level ? [level] : []), table.root);
panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []));
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
+10 -3
View File
@@ -5,6 +5,7 @@
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움)
* 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭
* 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐
* 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -15,6 +16,8 @@ export interface TabBars {
sub: HTMLElement;
/** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */
draw: (tab: TabId) => void;
/** 아래 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 목록 = 비움 */
setTools: (tools: HTMLElement[]) => void;
}
function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) {
@@ -35,6 +38,8 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
top.setAttribute("role", "tablist");
const sub = document.createElement("div");
sub.className = "b09-estimation__tabs";
const tools = document.createElement("div");
tools.className = "b09-estimation__tools";
const lastSeen = new Map<TabId, TabId>();
const draw = (tab: TabId): void => {
@@ -53,11 +58,11 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
const single = picked.length === 1;
sub.classList.toggle("b09-estimation__tabs--blank", single);
sub.setAttribute("role", single ? "presentation" : "tablist");
sub.setAttribute("aria-hidden", String(single));
if (single) {
const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" });
ghost.tabIndex = -1;
return void sub.replaceChildren(ghost);
ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음
return void sub.replaceChildren(ghost, tools);
}
sub.replaceChildren(
...picked.map((id) => {
@@ -65,7 +70,9 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
button.dataset.b09Tab = id;
return button;
}),
tools,
);
};
return { top, sub, draw };
const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items);
return { top, sub, draw, setTools };
}
+10 -10
View File
@@ -7,7 +7,8 @@
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* 도구 줄(200-1) = 안내 글 + 가로 열 수 + 보기 단추 한 줄 · 안내 글 길면 줄임표 + 공용 호버로 전문
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
* ========================================================================== */
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
@@ -176,7 +177,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 안내 글 = 도구 줄 한 칸(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint) return null;
hint.classList.add("b09-estimation__line-hint");
@@ -187,7 +188,7 @@ function lineHint(hint: HTMLElement | null): HTMLElement | null {
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
/** 묶음 탭 — 카드 = 카드 판(안내 · 가로 열 수 · 보기 단추 한 줄) · 표 = 머리(안내 + 보기 단추 한 줄) + 표 */
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */
export function renderGroupPanel(o: {
tab: TabId;
spec: TabTable;
@@ -196,6 +197,7 @@ export function renderGroupPanel(o: {
goto: (link: Link | null) => void;
makeTable: (rowClass?: (index: number) => string | null) => DataTable;
redraw: () => void;
setTools: (tools: HTMLElement[]) => void;
}): HTMLElement[] {
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
const lead = lineHint(o.hint);
@@ -221,17 +223,15 @@ export function renderGroupPanel(o: {
focus: at,
money: numberFormat(o.spec, "sum"),
qty: numberFormat(o.spec, "qty"),
lead, // 200-1 안내 글도 같은 줄
actions: [toggle], // 185-2 가로 열 수 단추와 한 줄
actions: [toggle],
placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽
});
cards.dataset.b09Table = o.tab;
return [cards];
return [cards, ...(lead ? [lead] : [])];
}
const bar = document.createElement("div");
bar.className = "b09-estimation__bar";
bar.append(...(lead ? [lead] : []), toggle);
o.setTools([toggle]);
const range = pickRange(o.spec.rows, o.focus);
const table = o.makeTable((i) => pickClass(range, i));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
return [bar, table.root];
return [table.root, ...(lead ? [lead] : [])];
}
@@ -0,0 +1,84 @@
/* 202-1 — 표 칸 아래로 간 안내 줄 몫이 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)에 들어가는지 단위검증 헬퍼.
* 카드 본문 = [표 칸][아래 줄][안내 줄 · 안내 줄 …](B08 `tableCard` 꼴) 가짜 DOM 에서 돌리고 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 win = { innerHeight: 900, scrollY: 0, addEventListener() {}, removeEventListener() {} };
const observers = [];
class RO {
constructor(fn) {
this.fn = fn;
observers.push(this);
}
observe() {}
disconnect() {}
}
const el = (name, css = {}, height = 0) => ({
name,
css,
height,
top: 0,
parentElement: null,
nextElementSibling: null,
isConnected: true,
style: {},
getBoundingClientRect() {
return { top: this.top, height: this.height };
},
});
function link(parent, ...kids) {
kids.forEach((k, i) => {
k.parentElement = parent;
k.nextElementSibling = kids[i + 1] ?? null;
});
return parent;
}
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 m = box.exports;
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40]
const body = el("body", { paddingBottom: "8px" });
const card = el("card", { paddingBottom: "12px", rowGap: "8px" });
const root = el("root");
const avg = el("avg", {}, 20);
const info = el("info", {}, 30);
const hint = el("hint", {}, 40);
link(body, card);
link(card, root, avg, info, hint);
root.top = 200;
m.fillViewport(root);
observers.forEach((o) => o.fn());
const out = { first: parseFloat(root.style.maxHeight) };
out.below = m.spaceBelow(root);
out.belowExpected = 3 * 8 + 20 + 30 + 40 + 12 + 8; // 줄 사이 8 셋 · 아래 줄 · 안내 둘 · 카드 · 본문 여백
out.bottom = root.top + out.first + out.below; // 문서 맨 아래 = 창 아래 끝(페이지 스크롤 0)
out.viewport = win.innerHeight;
// 안내 줄이 두 줄로 늘어남 → 표 칸이 그만큼 줄어듦
hint.height = 60;
observers.forEach((o) => o.fn());
out.afterHintGrow = parseFloat(root.style.maxHeight);
out.firstMinusGrow = out.first - 20;
console.log(JSON.stringify(out));
@@ -27,5 +27,7 @@ def test_자재_셋_값_없는_줄_숨김():
def test_표_넷_공용_카드_안():
assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4)
assert "createCard({ body: parts })" in SUMMARY
assert (
"createCard({ body: [toolbar, table.root, excel, notes] })" in MATERIAL
) # + 엑셀 알림(160-4) · 안내 = 아래(202-1)
assert "createCard({ body: [table, ...parts] })" in SUMMARY
+10 -6
View File
@@ -142,21 +142,21 @@ def test_화면_보기_바꿈_누름_이동():
out = run(
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
"\nconst gone = []; let redraws = 0; const focused = [];"
"\nconst gone = []; let redraws = 0; const focused = []; const calls = [];"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l),"
" redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table'),"
" redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table'),"
" focusRow: (i) => focused.push(i) }) };"
"\nconst [view] = renderGroupPanel(o);"
"\nconst btn = view.querySelector('[data-b09-group-view]');"
"\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);"
"\nconst label = btn.textContent;"
"\nview.querySelector('[data-at=\"2\"]').click();"
"\nview.querySelector('[data-at=\"0\"]').click();"
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
"\nbtn.click();"
"\nconst [bar2, view2] = renderGroupPanel(o);"
"\nconst [view2] = renderGroupPanel(o);"
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
" label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused,"
" kept: Object.fromEntries(kept) }));",
RAW,
)
@@ -171,7 +171,11 @@ def test_화면_보기_바꿈_누름_이동():
def test_잇기():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "if (isGroupTab(tab)) {" in shell and "renderGroupPanel({ tab, spec," in shell
assert (
"if (isGroupTab(tab)) {" in shell
and "renderGroupPanel({" in shell
and "setTools: bars.setTools" in shell
)
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text("utf-8")
assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state
assert '"b09-group-cols": { bucket: "pref", scope: "global" },' in state
@@ -54,7 +54,7 @@ const bars = createTabBars((t) => picks.push(t));
const view = () => ({
top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'],
b.dataset.b09Group, b.dataset.b09Tab ?? null]),
sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
sub: bars.sub.children.filter((b) => !b.classList.contains('b09-estimation__tools')).map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
role: bars.sub.attrs.role,
});
+1 -1
View File
@@ -49,7 +49,7 @@ def test_표_보기_줄_class_넘김():
[COMMON, TABS, GROUPS],
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
"\nlet extra = null;"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {},"
" makeTable: (e) => { extra = e; return { root: document.createElement('table'),"
" focusRow: () => {} }; } };"
"\nchosen.set('dg', 'table');"
+25 -18
View File
@@ -1,7 +1,7 @@
"""185-2 B09 묶음 탭 도구 줄 — 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
- 카드 보기 = 보기 바꿈 단추가 가로 열 수 줄(`ui-group-cards__bar`) 안 · 표 보기 = 머리 줄(`b09-estimation__bar`)
- 단추 · 라벨 = `white-space: nowrap` · 도구 줄 = 줄바꿈 없음(`flex-wrap: nowrap`)
- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만
- 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`)
"""
import json
@@ -27,40 +27,43 @@ def _block(css: str, selector: str) -> str:
def _panel(view: str, hint: str):
"""고른 보기 · 안내 글로 그린 뒤 보기 바꿈 단추가 든 줄 · 그 줄의 열 수 단추."""
"""고른 보기 · 안내 글로 그린 뒤 도구 묶음(보기 바꿈 단추 · 열 수 단추) · 판 안 머리 띠."""
return run(
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
f"\nchosen.set('dg', '{view}');"
"\nconst tools = [];"
f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}},"
" setTools: (t) => tools.push(...t),"
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
"\nconst out = renderGroupPanel(o);"
"\nconst top = out.find((e) => e.all('[data-b09-group-view]').length || e.dataset.b09GroupView);"
"\nconst btn = top.dataset.b09GroupView ? top : top.querySelector('[data-b09-group-view]');"
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar') ?? top;"
"\nconst btn = tools.find((e) => e.dataset.b09GroupView);"
"\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;"
"\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent,"
" bar: bar.className, cols: bar.all('[data-group-cols]').map((b) => b.dataset.groupCols) }));",
" cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols),"
" barBtn: bar ? bar.all('[data-b09-group-view]').length : null,"
" barCols: bar ? bar.all('[data-group-cols]').length : null }));",
RAW,
)
def test_카드_보기_단추가_가로_열수_줄에():
def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에():
out = _panel("cards", "null")
assert out["bar"] == "ui-group-cards__bar" and out["label"] == "표로 보기"
assert out["cols"] == ["1", "2", "3", "4"] # 같은 줄에 열 수 단추 넷
assert out["label"] == "표로 보기"
assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷
assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음
assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐
def test_카드_보기_안내_글은_같은_도구_줄에():
# 200-1 — 안내 글이 카드 위 따로 줄에서 도구 줄 안으로(상세 = test_200_1)
def test_카드_보기_안내_글은_카드_판_아래에():
# 202-1 — 안내 글 = 카드 판 아래(상세 = test_202_1)
out = _panel("cards", HINT)
assert out["n"] == 1 and out["first"] == "ui-group-cards"
assert out["bar"] == "ui-group-cards__bar"
assert out["n"] == 2 and out["first"] == "ui-group-cards"
def test_표_보기_단추는_머리_줄():
def test_표_보기_단추는_도구_묶음에():
out = _panel("table", "null")
assert out["bar"] == "b09-estimation__bar" and out["label"] == "박스보기"
assert out["label"] == "박스보기" and out["n"] == 1
assert out["cols"] == [] # 표 보기 = 열 수 단추 없음
@@ -73,5 +76,9 @@ def test_CSS_한_줄_nowrap():
)
assert "white-space: nowrap" in item and "flex: none" in item
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
assert "white-space: nowrap" in _block(page, ".b09-estimation__bar .ui-btn")
assert "flex-wrap: nowrap" in _block(page, ".b09-estimation__tools")
tools = _block(
page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label"
)
assert "white-space: nowrap" in tools
assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8")
@@ -1,8 +1,8 @@
"""200-1 B09 박스보기 도구 줄 한 줄 · 카드 같은 가로 줄 높이 같게.
"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게.
- 안내 글 · 가로 열 수 단추 · 보기 단추 = 같은 줄(카드 보기 = `ui-group-cards__bar` · 표 보기 = `b09-estimation__bar`)
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래
- 카드 제목 줄 · 합계 줄 바탕색(200-3)
"""
import json
@@ -32,43 +32,36 @@ def _block(css: str, selector: str) -> str:
def _panel(view: str, hint: str):
"""고른 보기 · 안내 글로 그린 뒤 도구 줄 구성 · 호버 기록."""
"""고른 보기 · 안내 글로 그린 뒤 안내 글 자리 · 호버 기록."""
return run(
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
f"\nchosen.set('dg', '{view}');"
f"\nconst hint = {hint};"
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {},"
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
"\nconst out = renderGroupPanel(o);"
"\nconst btn = out.flatMap((e) => (e.dataset.b09GroupView ? [e] : e.all('[data-b09-group-view]')))[0];"
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar');"
"\nconst h = hint ? bar.all('.b09-estimation__line-hint')[0] : null;"
"\nconst last = out[out.length - 1];"
"\nconst inBar = hint ? out.some((e) => e.all('.ui-group-cards__bar').some((b) => b.contains(hint))) : false;"
"\nconst call = hoverCalls[0];"
"\nconst wide = call ? call.text({ scrollWidth: 900, clientWidth: 300, textContent: 'T' }) : null;"
"\nconst fit = call ? call.text({ scrollWidth: 300, clientWidth: 300, textContent: 'T' }) : null;"
"\nconsole.log(JSON.stringify({ n: out.length, bar: bar.className,"
" cols: bar.all('[data-group-cols]').length, hint: !!h, hintInBar: h ? bar.contains(h) : false,"
" hintCls: h ? h.className : '', hover: hoverCalls.length, hoverIsHint: call ? call.root === h : false,"
" wide, fit, outer: out.map((e) => e.className) }));",
"\nconsole.log(JSON.stringify({ n: out.length, hintLast: !!hint && last === hint, inBar,"
" hintCls: hint ? hint.className : '', hover: hoverCalls.length,"
" hoverIsHint: call ? call.root === hint : false, wide, fit, first: out[0].tagName }));",
RAW,
)
def test_카드_보기_안내_글이_단추와_같은_줄():
def test_카드_보기_안내_글은_카드_판_아래():
out = _panel("cards", HINT)
assert out["n"] == 1 # 안내 글 따로 줄 없음 · 카드 판 하나
assert out["bar"] == "ui-group-cards__bar"
assert out["hint"] and out["hintInBar"] # 열 수 단추 · 보기 단추와 같은 도구 줄 안
assert out["cols"] == 4
assert out["n"] == 2 and out["hintLast"] and not out["inBar"] # 카드 판 + 안내 글(맨 아래)
assert "b09-estimation__line-hint" in out["hintCls"]
def test_표_보기_안내_글이_보기_단추와_같은_줄():
def test_표_보기_안내_글은_표_아래():
out = _panel("table", HINT)
assert out["n"] == 2 # 머리 줄 + 표
assert out["bar"] == "b09-estimation__bar"
assert out["hint"] and out["hintInBar"]
assert out["n"] == 2 and out["first"] == "TABLE" and out["hintLast"] # 표 + 안내 글
def test_안내_글_잘렸을_때만_공용_호버로_전문():
@@ -82,7 +75,7 @@ def test_안내_글_잘렸을_때만_공용_호버로_전문():
def test_안내_글_없으면_호버도_없음():
for view in ("cards", "table"):
out = _panel(view, "null")
assert out["hint"] is False and out["hover"] == 0
assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0
def test_CSS_안내_글_줄임표_한_줄():
@@ -95,10 +88,6 @@ def test_CSS_안내_글_줄임표_한_줄():
"min-width: 0",
):
assert need in hint, need
assert "flex: 1 1 0" in hint
# 표 보기 머리 줄 = 줄바꿈 없음 · 같은 높이 가운데
bars = re.findall(r"\.b09-estimation__bar\s*\{([^}]*)\}", page)
assert any("flex-wrap: nowrap" in b and "align-items: center" in b for b in bars)
def test_CSS_카드_같은_줄_높이_같게():
@@ -112,10 +101,9 @@ def test_CSS_카드_같은_줄_높이_같게():
def test_잇기():
groups = (ROOT / GROUPS).read_text("utf-8")
assert "lead," in groups and "actions: [toggle]" in groups
assert "attachHoverInfo(hint," in groups
assert "attachHoverInfo(hint," in groups and "placeTools: o.setTools" in groups
cards = (ROOT / COMMON).read_text("utf-8")
assert "if (opts.lead) bar.append(opts.lead);" in cards
assert "opts.lead" not in cards and "if (opts.placeTools) {" in cards
# ── 200-3 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ─────────────────────────
@@ -0,0 +1,172 @@
"""202-1 B08 · B09 표 칸 — 안내 · 경고 줄 = 맨 아래 · 박스보기 · 가로 열 수 = 보조 탭 줄 오른쪽.
- B09 안내 줄 = 표 · 카드 판 아래(마지막 자식) · 묶음 탭 도구(보기 단추 · 열 수) = 아래 탭 줄 오른쪽 · 단독 탭 빈 줄에서도
- B08 안내 · 경고 줄 = 표 · 아래 줄 뒤 맨 아래(토공 · 운반 시트 · 자재 · 구조물 집계)
- 창 끝까지 셈(199-1) = 아래로 간 안내 줄 몫 포함
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
B09 = "B09_Estimation/B09_Estimation_UI_"
BAR = f"{B09}TabBar.ts"
DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0}
def _block(css: str, selector: str) -> str:
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
# ── B09 보조 탭 줄 오른쪽 도구 ──────────────────────────────────────────────
def test_도구_자리_보조_줄_오른쪽_빈_줄에서도_보임():
out = run(
[TABS, BAR],
"""
const bars = createTabBars(() => {});
const mk = (n) => { const b = document.createElement('button'); b.textContent = n; return b; };
const snap = () => ({
kids: bars.sub.children.map((c) => (c.classList.contains('b09-estimation__tools') ? 'TOOLS' : c.textContent)),
last: bars.sub.children[bars.sub.children.length - 1].children.map((x) => x.textContent),
});
bars.draw('dg');
bars.setTools([mk('가'), mk('나')]);
const a = snap();
bars.draw('compare'); // 다른 묶음으로 옮겨도 도구 자리는 그대로 마지막
const b = snap();
bars.draw('cost'); // 단독 탭 = 빈 보조 줄
const c = snap();
const ghost = bars.sub.children[0];
bars.setTools([]);
const d = snap();
console.log(JSON.stringify({
a: a.kids, aLast: a.last,
b: b.kids.slice(-1), bLast: b.last,
c: c.kids, cLast: c.last,
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
subHidden: bars.sub.attrs['aria-hidden'] ?? null, ghostHidden: ghost.attrs['aria-hidden'],
dLast: d.last.length,
}));
""",
)
tools = "TOOLS"
assert out["a"] == ["일위대가목록표", "일위대가표", tools] # 아래 탭 뒤 오른쪽 자리
assert out["aLast"] == ["가", "나"]
assert out["b"] == [tools] and out["bLast"] == ["가", "나"] # 탭을 옮겨도 도구는 그대로
assert out["blank"] and len(out["c"]) == 2 and out["c"][1] == tools # 숨은 단추 + 도구
assert out["cLast"] == ["가", "나"]
assert out["subHidden"] is None and out["ghostHidden"] == "true" # 줄 전체가 아니라 숨은 단추만
assert out["dLast"] == 0 # 비움
def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
"""쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음."""
out = run(
[COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
"\nconst bars = createTabBars(() => {});"
"\nconst hint = document.createElement('p');"
"\nconst res = {};"
"\nfor (const view of ['cards', 'table']) {"
"\n chosen.set('dg', view);"
"\n bars.draw('dg'); bars.setTools([]);"
"\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }),"
" setTools: bars.setTools });"
"\n const tools = bars.sub.children[bars.sub.children.length - 1];"
"\n res[view] = { inRow: tools.all('[data-b09-group-view]').map((b) => b.textContent),"
" cols: tools.all('[data-group-cols]').length,"
" inPanel: panel.flatMap((e) => [e, ...e.all('[data-b09-group-view]'), ...e.all('[data-group-cols]')])"
".filter((e) => e.dataset.b09GroupView || e.dataset.groupCols).length,"
" hintLast: panel[panel.length - 1] === hint, n: panel.length };"
"\n}"
"\nconsole.log(JSON.stringify(res));",
RAW,
)
assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4
assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0
for view in ("cards", "table"):
assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음
assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래)
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "panel.replaceChildren(table.root, ...(hint ? [hint] : []))" in shell
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
assert "setTools: bars.setTools" in shell
def test_CSS_도구_오른쪽_맞춤_빈_줄_숨김_규칙에_안_걸림():
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
tools = _block(css, ".b09-estimation__tools")
assert "margin-left: auto" in tools and "flex-wrap: nowrap" in tools
hidden = _block(css, ".b09-estimation__tabs--blank > .ui-btn")
assert "visibility: hidden" in hidden # 직계 단추만 — 도구 묶음은 안 숨김
assert ".b09-estimation__bar" not in css # 옛 머리 줄(안내 + 단추) 풂
# ── B08 안내 · 경고 줄 맨 아래 ──────────────────────────────────────────────
def test_B08_표_카드_차례_표_아래줄_안내():
src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text("utf-8")
fn = re.search(r"export function tableCard\(.*?\n}\n", src, re.S)
assert fn
code = fn.group(0).replace("export ", "")
js = (
"const createCard = ({ body }) => body;\n"
+ code
+ "\nconsole.log(JSON.stringify(tableCard(['i1', 'i2'], 'T', ['a1', 'a2'])));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=js,
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["T", "a1", "a2", "i1", "i2"] # 표 · 아래 줄 · 안내 줄
def test_B08_자재_구조물_안내_줄_표_아래():
material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8")
assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material
assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래)
summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8")
assert "createCard({ body: [table, ...parts] })" in summary
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8")
assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar")
# ── 창 끝까지 셈(199-1)에 아래 줄 몫 ────────────────────────────────────────
def test_창_끝까지_셈에_아래_안내_줄_포함():
proc = subprocess.run(
["node", str(ROOT / "resources" / "tester" / "helper_202_1_fill_below.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
r = json.loads(proc.stdout)
assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백
assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦
+12 -8
View File
@@ -65,10 +65,10 @@ export interface GroupCardsOptions {
focus?: number;
money?: (value: number | null | undefined) => string;
qty?: (value: number | null | undefined) => string;
/** 머리 띠 가운데 안내 글(200-1 · 단추와 한 줄 — 늘어나는 칸) */
lead?: HTMLElement | null;
/** 머리 띠 끝 단추(예 [표로 보기]) */
actions?: HTMLElement[];
/** 주면 가로 열 수 · `actions` 를 머리 띠에 안 넣고 이 함수로 넘김(202-1 부르는 쪽 보조 탭 줄 오른쪽) */
placeTools?: (tools: HTMLElement[]) => void;
}
export const GROUP_COLUMNS = [1, 2, 3, 4] as const;
@@ -210,12 +210,16 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
return b;
});
pick.append(...colButtons);
bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`));
if (opts.lead) bar.append(opts.lead);
const tail = document.createElement("div");
tail.className = "ui-group-cards__actions";
tail.append(...(opts.actions ?? []));
bar.append(tail);
const count = span("ui-group-cards__count", `묶음 ${opts.groups.length}`);
if (opts.placeTools) {
bar.append(count);
opts.placeTools([pick, ...(opts.actions ?? [])]);
} else {
const tail = document.createElement("div");
tail.className = "ui-group-cards__actions";
tail.append(...(opts.actions ?? []));
bar.append(pick, count, tail);
}
root.append(bar, grid);
grid.addEventListener("click", (event) => {