diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index da26b1d54..f8c318187 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -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 { diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 5316e7f84..0f148cd02 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -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(); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index f1c338629..cf2275cd9 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -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); } diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index b54932b76..ba9a28b1e 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -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); diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 27be4e313..3b0313338 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -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; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index cc619ea1f..8b06c0a4d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -184,6 +184,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]; @@ -246,11 +247,20 @@ 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, + }), ); } const table = makeTable(); - panel.replaceChildren(...(hint ? [hint] : []), table.root); + panel.replaceChildren(table.root, ...(hint ? [hint] : [])); // 202-1 안내 줄 = 표 아래 if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts index c80caaf7e..3ce0e2a2f 100644 --- a/B09_Estimation/B09_Estimation_UI_TabBar.ts +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -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(); 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 }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 666f0ea42..313a30d7a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -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(); -/** 묶음 탭 — 카드 = 카드 판(안내 · 가로 열 수 · 보기 단추 한 줄) · 표 = 머리(안내 + 보기 단추 한 줄) + 표 */ +/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `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] : [])]; } diff --git a/resources/tester/helper_202_1_fill_below.cjs b/resources/tester/helper_202_1_fill_below.cjs new file mode 100644 index 000000000..73fd59feb --- /dev/null +++ b/resources/tester/helper_202_1_fill_below.cjs @@ -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)); diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index b21cffa34..5253fcfa4 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -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 diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index aaee869e7..4ac6abbc9 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -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 diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index c1e288330..1752de606 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -55,7 +55,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, }); diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index 05d14ce34..f078bc23f 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -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');" diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index f28188e2d..4aebd6501 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -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") diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index eea635ee6..e2ac75c0f 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -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 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ───────────────────────── diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py new file mode 100644 index 000000000..8847b62e6 --- /dev/null +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -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"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦 diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 4eb6cc64b..976100317 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -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) => {