feat(B06): 좌측 세부 컨테이너 누름 · 초점 = 그 구조물 형상 고름(191-1)
- 값 바꿀 때 고름 길(pickAt)을 click · 키보드 초점에도 씀 — 칸 제 동작 뒤 setTimeout - 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점 되살림 - 시험 test_191_1_box_press_pick + 흉내 DOM 헬퍼 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
@@ -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<HTMLElement>("fieldset[data-spec-container]");
|
||||
const pickAt = (el: HTMLElement): void => {
|
||||
const box = el.closest<HTMLElement>("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<HTMLElement>(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<string> } | null = null;
|
||||
panel.root.addEventListener("specpaint", () => {
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user