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