From 1cd8668d8a917c49f7ec56a12659f556c5c8a831 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:28:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(b09):=20=EB=AC=B6=EC=9D=8C=20=ED=83=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B9=A8=EA=B0=95=20=3D=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=E2=80=94=20=EB=B9=A8=EA=B0=95=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=C2=B7=20Esc=20=3D=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=92=82=20=C2=B7=20=EB=88=84=EB=A5=B8=20?= =?UTF-8?q?=EC=A4=84=EB=A7=8C=20=EA=B3=A0=EB=A6=84=20(248-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../B09_Estimation_UI_Tabs_Groups.ts | 41 ++++++++- resources/tester/helper_b09_group_cards.py | 9 +- resources/tester/test_171_2_group_cards.py | 4 +- resources/tester/test_171_3_b09_group_tabs.py | 6 +- .../test_248_1_b09_group_jump_unpick.py | 90 +++++++++++++++++++ ui_template/ui_template_group_cards.ts | 11 ++- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_248_1_b09_group_jump_unpick.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index bc57acacb..8618460f7 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -6,6 +6,8 @@ * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 + * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · + * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) @@ -17,6 +19,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, createGroupCards, + unpickCards, type GroupCard, type GroupFoot, type GroupLine, @@ -185,6 +188,21 @@ export function lineHint(hint: HTMLElement | null): HTMLElement | null { return hint; } +/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */ +let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null; +let keysOn = false; +const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]); + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return; + if ((event.target as Element | null)?.closest?.("input, textarea, select")) return; + live.unpick(event.key === "Escape"); + }); +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); @@ -227,11 +245,28 @@ export function renderGroupPanel(o: { placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; + live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; + listenKeys(); return [cards, ...(lead ? [lead] : [])]; } 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)); + let range = pickRange(o.spec.rows, o.focus); + let chose = -1; // 누른 줄(다시 그려도 유지) + const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); + // 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로) + const mark = (row: number): void => { + range = null; + chose = row; + for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus"); + table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus"); + }; + table.root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + mark(tr ? Number(tr.dataset.i) : chose); + }); + live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) }; + listenKeys(); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) return [table.root, ...(lead ? [lead] : [])]; } diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 11887582b..5fb08a813 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -66,6 +66,9 @@ class E { setAttribute(k, v) { this.attrs[k] = String(v); } scrollIntoView() { this.scrolled = true; } matches(sel) { + if (sel.includes(",")) return sel.split(",").some((s) => this.matches(s.trim())); + const tag = sel.match(/^([a-z]+)([.[].*)$/); // tr.is-pick · tr[data-i="3"] + if (tag) return this.matches(tag[1]) && this.matches(tag[2]); if (sel.startsWith(".")) return this.cls.has(sel.slice(1)); const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/); const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : ""; @@ -75,11 +78,15 @@ class E { closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; } all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; } querySelector(sel) { return this.all(sel)[0] ?? null; } + querySelectorAll(sel) { return this.all(sel); } + get isConnected() { return true; } contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; } get text() { return this.textContent + this.children.map((c) => c.text).join(""); } click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); } } -globalThis.document = { createElement: (t) => new E(t) }; +const docKeys = []; // 248-1 문서 keydown — key(이름, 대상) 로 부름 +globalThis.document = { createElement: (t) => new E(t), addEventListener: (t, f) => t === "keydown" && docKeys.push(f) }; +const key = (k, target = null) => docKeys.forEach((f) => f({ key: k, target })); globalThis.requestAnimationFrame = (f) => f(); """ diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 3a5fe8211..a57b32cff 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -122,9 +122,9 @@ def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 박스 스크롤(248-1 줄 강조 안 붙임) assert out["first"] == [False] * 4 + [True] + [False] * 8 - assert out["focus"] == [True, True] + assert out["focus"] == [False, True] assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 4ac6abbc9..139aab8d6 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -149,10 +149,10 @@ def test_화면_보기_바꿈_누름_이동(): "\nconst [view] = renderGroupPanel(o);" "\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');" + "\nview.querySelector('[data-at=\"2\"]').click();" + "\nview.querySelector('[data-at=\"0\"]').click();" "\nbtn.click();" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," @@ -162,7 +162,7 @@ def test_화면_보기_바꿈_누름_이동(): ) assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 - assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py new file mode 100644 index 000000000..0ebb03c45 --- /dev/null +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -0,0 +1,90 @@ +"""248-1 B09 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) 다른 탭 이동 빨강 = 설계내역서(242-1) 규칙. + +- 이동해 온 호표 = 빨강 하나 — 카드 = 박스 is-pick 만(줄 is-focus 안 붙음) · 표 = 구간 is-pick 만 · + 공용 focusRow(at, false) = 스크롤만 +- 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름(is-focus) · Esc = 고름도 풂 · 다시 그려도 그대로 +- 글 칸 안 키 · 다른 키 = 그대로 +""" + +import json +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 +from resources.tester.test_171_3_b09_group_tabs import DG # noqa: E402 +from resources.tester.test_181_2_b09_box_pick import ROWS # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +INPUT = "{ closest: (s) => (s.includes('input') ? {} : null) }" # 글 칸 안 키 + + +def test_카드_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable: () => null };" + "\nconst marks = (v) => [v.all('.is-pick').length, v.all('.is-focus').map((e) => e.dataset.at ?? 'box')];" + "\nconst out = {};" + "\nlet [v] = renderGroupPanel(o); out.came = marks(v);" + "\nv.querySelector('[data-at=\"2\"]').click(); out.line = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('.ui-group-card').click(); out.box = marks(v);" + "\n[v] = renderGroupPanel(o); key('a'); key('ArrowDown', " + + INPUT + + "); out.kept = marks(v);" + "\nkey('ArrowDown'); out.arrow = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('[data-at=\"2\"]').click(); key('Escape'); out.esc = marks(v);" + "\nconsole.log(JSON.stringify(out));", + RAW, + ) + assert out["came"] == [1, []] # 박스 빨강만 · 줄 강조 없음 + assert out["line"] == [0, ["2"]] # 누른 줄만 고름 + assert out["box"] == [0, []] # 카드 빈 곳 누름 = 빨강만 풂 + assert out["kept"] == [1, []] # 다른 키 · 글 칸 안 = 그대로 + assert out["arrow"] == [0, []] + assert out["esc"] == [0, []] # Esc = 고름도 풂 + + +def test_표_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" + "\nlet extra = null; let root = null; const scrolled = [];" + "\nconst makeTable = (e) => { extra = e; root = document.createElement('table');" + " spec.rows.forEach((_, i) => { const tr = document.createElement('tr'); tr.dataset.i = String(i);" + " tr.className = e(i) ?? ''; tr.append(document.createElement('td')); root.append(tr); });" + " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable };" + "\nchosen.set('dg', 'table');" + "\nconst marks = () => [root.children.map((tr) => tr.className || null)," + " spec.rows.map((_, i) => extra(i))];" + "\nconst out = {};" + "\nrenderGroupPanel(o); out.came = marks(); out.scrolled = [...scrolled];" + "\nroot.children[1].children[0].click(); out.click = marks();" + "\nrenderGroupPanel(o); key('Enter'); key('ArrowUp', " + INPUT + "); out.kept = marks()[0];" + "\nkey('ArrowUp'); out.arrow = marks();" + "\nrenderGroupPanel(o); root.children[5].click(); key('Escape'); out.esc = marks();" + "\nconsole.log(JSON.stringify(out));", + ) + red = [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", None] + none = [None] * 8 + assert out["came"] == [red, red] # 구간 빨강만 + assert out["scrolled"] == [[4, False]] # 스크롤만(공용 강조 안 붙임) + focus1 = [None, "is-focus"] + [None] * 6 + assert out["click"] == [focus1, focus1] # 누른 줄만 · 다시 그려도 + assert out["kept"] == red + assert out["arrow"] == [none, none] + assert out["esc"] == [none, none] + + +def test_배선(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "table.focusRow(at, false)" in groups + common = (ROOT / COMMON).read_text("utf-8") + assert 'el?.classList.add("is-focus")' not in common + assert "unpickCards(grid, true);" in common diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 976100317..3a7fb9b02 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -7,7 +7,7 @@ * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) * 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 - * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) + * (`is-pick`) + 스크롤만(248-1 빨강 하나) · 누름 = 빨강 풂 · 누른 줄만 고름(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -224,7 +224,9 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); + unpickCards(grid, true); if (!el || !grid.contains(el)) return; + el.classList.add("is-focus"); opts.onPick?.(Number(el.dataset.at)); }); @@ -232,8 +234,13 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); box?.classList.add("is-pick"); - el?.classList.add("is-focus"); requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; } + +/** 248-1 박스 빨강(`is-pick`) 풂 · lines = 고른 줄(`is-focus`)도 */ +export function unpickCards(root: ParentNode, lines = false): void { + for (const el of root.querySelectorAll(lines ? ".is-pick, .is-focus" : ".is-pick")) + el.classList.remove("is-pick", "is-focus"); +}