From 50b45314a8df0f3d5cb0894407278e2c9e391b6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:22:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B06):=20=EC=A2=8C=EC=B8=A1=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EC=B4=88=EC=A0=90=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=98=95=EC=83=81=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84(191-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 값 바꿀 때 고름 길(pickAt)을 click · 키보드 초점에도 씀 — 칸 제 동작 뒤 setTimeout - 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점 되살림 - 시험 test_191_1_box_press_pick + 흉내 DOM 헬퍼 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- ...06_Section_UI_Page_Structures_Selection.ts | 35 ++- .../tester/helper_191_1_box_press_pick.cjs | 233 ++++++++++++++++++ resources/tester/test_191_1_box_press_pick.py | 59 +++++ 3 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_191_1_box_press_pick.cjs create mode 100644 resources/tester/test_191_1_box_press_pick.py diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index bdd0f2c51..6ce5b40a9 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -119,17 +119,38 @@ export function wireStructureSelection( revealed = field; reveal?.(chainageM); }; - panel.root.addEventListener("focusin", (event) => { - if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null; - }); - panel.root.addEventListener("change", ({ target: el }) => { - const box = (el as HTMLElement).closest("fieldset[data-spec-container]"); + const pickAt = (el: HTMLElement): void => { + const box = el.closest("fieldset[data-spec-container]"); const chainageM = panel.currentChainage(); if (!box || chainageM === null) return; - revealAt(chainageM, fieldOf(el as HTMLElement)); + revealAt(chainageM, fieldOf(el)); // 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름 - if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box); + if (!el.closest('[data-spec-item="outlet.tiers"]')) pickBox(box); + }; + // 값을 안 바꾸고 상자를 누르거나 칸에 초점만 줘도 같은 고름(191-1) — 칸 제 동작(누름 · 값 반영) 뒤에 + // 돌림 · 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점을 되살림 + const pressAt = (el: HTMLElement): void => { + setTimeout(() => { + const focused = document.activeElement as HTMLElement | null; + const named = focused?.closest("[data-spec-item]") && panel.root.contains(focused); + const field = named ? fieldOf(focused!) : null; + pickAt(el); + if (!field || focused!.isConnected) return; + // ponytail: 같은 칸 이름 · 같은 태그 첫 칸 — 한 칸에 같은 태그가 여럿이면 그 차례까지 맞출 것 + [...panel.root.querySelectorAll(focused!.tagName)] + .find((next) => fieldOf(next) === field) + ?.focus(); + }, 0); + }; + let pointing = false; // 누름이 준 초점은 click 이 맡음 — 초점 고름은 키보드(Tab) 몫 + panel.root.addEventListener("pointerdown", () => (pointing = true)); + panel.root.addEventListener("keydown", () => (pointing = false)); + panel.root.addEventListener("click", ({ target: el }) => pressAt(el as HTMLElement)); + panel.root.addEventListener("focusin", (event) => { + if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null; + if (!pointing) pressAt(event.target as HTMLElement); }); + panel.root.addEventListener("change", ({ target: el }) => pickAt(el as HTMLElement)); // 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만 let painted: { chainageM: number | null; keys: Set } | null = null; panel.root.addEventListener("specpaint", () => { diff --git a/resources/tester/helper_191_1_box_press_pick.cjs b/resources/tester/helper_191_1_box_press_pick.cjs new file mode 100644 index 000000000..7c1268de8 --- /dev/null +++ b/resources/tester/helper_191_1_box_press_pick.cjs @@ -0,0 +1,233 @@ +/* 191-1 좌측 세부 컨테이너 누름 · 초점 = 형상 고름 — 실제 `wireStructureSelection` 을 그 자리에서 + * 트랜스파일해 흉내 DOM 에서 돌림. 고름(select)이 폼을 다시 그리는 것까지 흉내. 짝 = `test_191_1_box_press_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag, dataset = {}) { + this.tagName = tag.toUpperCase(); + this.dataset = dataset; + this.children = []; + this.parentElement = null; + this._listeners = {}; + } + append(...kids) { + kids.forEach((k) => { + k.parentElement = this; + this.children.push(k); + }); + } + replaceChildren(...kids) { + this.children.forEach((k) => (k.parentElement = null)); + this.children = []; + this.append(...kids); + } + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + matches(sel) { + return sel.split(",").some((s) => { + const m = s.trim().match(/^(\w+)?(?:\[data-([\w-]+)(?:="([^"]*)")?\])?$/); + if (!m) return false; + const [, tag, attr, value] = m; + if (tag && this.tagName !== tag.toUpperCase()) return false; + if (!attr) return true; + const key = attr.replace(/-(\w)/g, (_, c) => c.toUpperCase()); + return value === undefined ? this.dataset[key] !== undefined : this.dataset[key] === value; + }); + } + closest(sel) { + for (let n = this; n; n = n.parentElement) if (n.matches?.(sel)) return n; + return null; + } + contains(node) { + for (let n = node; n; n = n.parentElement) if (n === this) return true; + return false; + } + get isConnected() { + return panelRoot.contains(this); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + n.children.forEach((k) => { + if (k.matches(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + focus() { + globalThis.document.activeElement = this; + } +} +// 흉내 이벤트 — 대상에서 뿌리까지 거품 +function fire(target, type) { + for (let n = target; n; n = n.parentElement) + (n._listeners[type] ?? []).forEach((fn) => fn({ type, target })); +} + +globalThis.document = { activeElement: null }; +const timers = []; +globalThis.setTimeout = (fn) => timers.push(fn); +const flush = () => { + while (timers.length) timers.shift()(); +}; +globalThis.queueMicrotask = (fn) => fn(); + +// 좌측 폼 — 상자 넷(유입 · 유출 · 관 · 흙막이 둘) · 칸마다 data-spec-item 입력 하나 +const BOXES = [ + ["inlet", undefined, "inlet.kind"], + ["outlet", undefined, "outlet.tiers"], + ["pipe", undefined, "pipe.diameter"], + ["soil_add", "0", "soil.height"], + ["soil_add", "1", "soil.height"], +]; +const panelRoot = new FakeNode("div"); +let paints = 0; +function paint() { + paints += 1; + panelRoot.replaceChildren( + ...BOXES.map(([id, index, item]) => { + const box = new FakeNode("fieldset", { specContainer: id, specIndex: index }); + const legend = new FakeNode("legend"); + const row = new FakeNode("label", { specItem: item }); + const input = new FakeNode("input"); + row.append(input); + box.append(legend, row); + return box; + }), + ); +} +paint(); +const boxOf = (id, index) => + panelRoot + .querySelectorAll("fieldset") + .find((b) => b.dataset.specContainer === id && b.dataset.specIndex === index); +const inputOf = (id, index) => boxOf(id, index).querySelectorAll("input")[0]; + +const CH = 120; +const SECTION = { chainage_m: CH, culvert: {} }; +let highlight = null; +const log = []; +const stationControls = { + structureSpan: { ownerOf: () => SECTION }, + inletStructure: { set: () => {}, valueFor: () => "auto" }, + revetOffset: { + select: (chainageM, key) => { + highlight = key; + log.push(["select", chainageM, key]); + }, + highlightFor: () => highlight, + }, + ford: { select: () => {} }, + box: { select: () => {} }, +}; +const panel = { + root: panelRoot, + currentChainage: () => CH, + showAtChainage: () => paint(), // 고름이 폼을 다시 그림 — 누른 칸이 사라짐 + releasePipe: () => {}, + facility: { onInletStructureChange: () => {}, setInletStructure: () => {} }, +}; + +function loadSelection() { + const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Selection.ts"); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const stubs = { "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} } }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]); + return box.exports; +} +loadSelection().wireStructureSelection( + stationControls, + () => ({ cross_sections: [SECTION] }), + panel, + (chainageM, key) => log.push(["remember", chainageM, key]), + (chainageM) => log.push(["card", chainageM]), + (chainageM) => log.push(["reveal", chainageM]), +); + +const reset = () => { + highlight = null; + log.length = 0; + document.activeElement = null; +}; +// 마우스 누름 — pointerdown · (칸이면 초점) · click +function press(target, focus = false) { + fire(target, "pointerdown"); + if (focus) { + target.focus(); + fire(target, "focusin"); + } + fire(target, "click"); + flush(); +} +const out = {}; + +// 값 바꿈(지금 길) — 기준 +reset(); +inputOf("inlet").focus(); +fire(inputOf("inlet"), "change"); +out.change = { highlight, log: [...log] }; + +// 빈 곳 · 제목 누름 = 같은 결과 +reset(); +press(boxOf("inlet")); +out.pressBox = { highlight, log: [...log] }; +reset(); +press(boxOf("inlet").children[0]); +out.pressLegend = { highlight, log: [...log] }; + +// 입력 칸 누름(초점) — 고름 + 다시 그린 새 칸에 초점 +reset(); +const before = paints; +const old = inputOf("pipe"); +press(old, true); +out.pressInput = { + highlight, + repainted: paints > before, + oldConnected: old.isConnected, + focusSame: document.activeElement === inputOf("pipe"), +}; + +// 키보드(Tab) 초점 = 고름 · 다른 상자 누르면 바뀜 +reset(); +const tabbed = inputOf("soil_add", "1"); +fire(tabbed, "keydown"); +tabbed.focus(); +fire(tabbed, "focusin"); +flush(); +out.tab = { highlight, focusSame: document.activeElement === inputOf("soil_add", "1") }; +press(boxOf("soil_add", "0")); +out.switched = highlight; +press(boxOf("outlet")); +out.switched2 = highlight; + +// 누름이 준 초점은 click 전엔 안 고름(누른 칸을 click 전에 지우지 않음) +reset(); +const target = inputOf("pipe"); +fire(target, "pointerdown"); +target.focus(); +fire(target, "focusin"); +flush(); +out.focusBeforeClick = { highlight, connected: target.isConnected }; + +// 단 수 칸 누름 = 값 바꿀 때처럼 안 고름 +reset(); +press(inputOf("outlet"), true); +out.tiers = highlight; + +// 같은 상자 거듭 누름 = 한 번만 고름 · 한 번만 넘김 +reset(); +press(boxOf("inlet")); +press(boxOf("inlet")); +out.twice = log.filter((e) => e[0] === "select" || e[0] === "reveal").length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_191_1_box_press_pick.py b/resources/tester/test_191_1_box_press_pick.py new file mode 100644 index 000000000..a8a0914fe --- /dev/null +++ b/resources/tester/test_191_1_box_press_pick.py @@ -0,0 +1,59 @@ +"""191-1 B06 좌측 세부 컨테이너 누름 · 초점 = 그 구조물 형상 고름(값 바꿀 때와 같은 길). + +화면 TS 를 흉내 DOM 에서 돌린 값(`helper_191_1_box_press_pick.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_191_1_box_press_pick.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_빈곳_누름_값바꿈과_같은_결과(out: dict) -> None: + assert out["change"]["highlight"] == "inlet" + assert out["pressBox"] == out["change"] # 고름 · 카드 · 넘김(스크롤) 차례까지 같음 + + +def test_제목_누름_고름(out: dict) -> None: + assert out["pressLegend"]["highlight"] == "inlet" + assert ["select", 120, "inlet"] in out["pressLegend"]["log"] + + +def test_입력칸_누름_고름_초점_되살림(out: dict) -> None: + assert out["pressInput"] == { + "highlight": "pipe", + "repainted": True, + "oldConnected": False, + "focusSame": True, + } + + +def test_키보드_초점_고름_다른상자_바뀜(out: dict) -> None: + assert out["tab"] == {"highlight": "extra1", "focusSame": True} + assert out["switched"] == "extra0" + assert out["switched2"] == "outlet" + + +def test_누름_초점은_click_뒤(out: dict) -> None: + assert out["focusBeforeClick"] == {"highlight": None, "connected": True} + + +def test_단수칸_안고름_거듭누름_한번(out: dict) -> None: + assert out["tiers"] is None + assert out["twice"] == 2 From 38df1740aac727a5d83436d2bcc1f8848ad2de47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:23:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(plan):=2010-10=20=EB=91=98=EC=A7=B8?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C=20=EC=9A=94=EA=B5=AC=2038~43(186~191?= =?UTF-8?q?=EC=9E=A5)?= 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_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 97bb37c80..4cdf59a87 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -52,3 +52,15 @@ 35. 자재단가대비표의에서 견적항목의 경우 단가를 수기입력하는 정보들만 견적서를 첨부하는 글자가 보이게 반영 36. 자재단가대비표의 출처는 있어야하는 정보가 맞으나 현재 페이지에서는 중요한 정보가 아니니 출처정보는 마우스커서를 올린 셀의 정보를 보이게 하는데 숫자들의 의미를 모름 풀어서 짧게 정보를 보여줘. 37. 4번 요청에 이어 프로그램 전역에서 호버기능으로 추가정보를 보여줄때의 공용양식이 있었으면 함. +38. (7번 보충) 흙막이류 횡단도 형상 반영 문제_랩탑 메인 프로젝트 14+12측점임. 데이터 확인 +39. 종단그래프 상단 초기화버튼부터 쉬프트 상하버튼까지 우측 배수유역도와 새창 버튼과 사이즈를 유사하게 변경. 형태가 같을 필요는 없음. 필요한 경우 템플릿으로 만들어서 적용해도됨. +40. 종단페이지에서 배관지점을 하나찌고 다시 일반 측점을 찍은 상태에서 횡단으로 넘어가면 배관지점이 일반측점을 찍은 상태가 해제되고 직전 배관측점을 지정한곳으로 이동. 사용자가 최종선택한 정보를 가진 변수가 제대로 동작하는 확인 +41. 구조물중 여전히 노면공의 항목을 선택하는게 문제가 있음. + 1. 좌측 패널에서 구조물 리스트를 선택하는 경우 해당지점에 대한 전체측점 횡단도의 포현은 좋음 + 2. 이때 횡단도 내부에 구조물 형상도 하이라이트 되는건 좋음. + 3. 구조물 형상을 선택할때는 전체 측점들의 컨테이너와 구조물 형상 하이라이트 반영(현재 선택안됨) + 4. 이때 특정 하나의 횡단도를 선택한다면 구조물 리스트와 컨테이너 하이라이트를 끄고 선택된 횡단도를 기준으로 다시 표현되어야함. + 5. 여러 측점을 거치기때문에 하나의 측점 횡단도를 기준으로 삼기 어려움. 종단에서 표현 안되게 + 6. 단, 종단 세로선이나 횡단도를 선택할때는 해당 지점에 대한 하이라이트는 되어야함. +42. B06의 집수정 형상 선택에 대해 조금더 넓은 영역이 선택할 수 있게 변경(날개벽 선택 참고 가상의 선을 그려서 사각 형태의 가상의 영역을 선택영역으로 포함.) +43. B06 횡단도 좌측 패널에서 세부 컨테이너의 정보를 변경하면 해당 구조물 형상을 잘 선택되고 있는데 해당 패널의 컨테이너 영역을 선택해도 변경되게 해줘.