From f570729f7c6a15910bf17e20edf8ef856ca09e39 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:45:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(b06):=20=EC=84=A0=ED=83=9D=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20[=EC=82=AD=EC=A0=9C]=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20B06=20=EC=A1=B0=EC=A0=95=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=EC=9D=B4=EB=A6=84=ED=91=9C=EB=8A=94=20=EA=B8=80=20?= =?UTF-8?q?=EC=B9=B8=EB=A7=8C=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EB=9E=98=20=EA=B8=80=EC=97=90=20=E3=80=8C=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=E3=80=8D=20=EC=95=88=20=EC=84=9E=EC=9E=84=20(294-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setGroupLabel 이 legend 를 통째로 다시 써 [삭제] 단추가 지워지고 baseLabel 에 「도수로/돌붙임 삭제」가 섞이던 것 - 첫 글 마디만 고침 — 도수로/돌붙임 · 날개벽 등 [삭제] 가진 상자 모두 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index e194f2270..394bfae5d 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -101,13 +101,17 @@ function slotOf(side: "inlet" | "outlet"): HTMLElement { * 나오지 않는다(2026-08-29 사용자 보고: 단 수·옵션이 사라짐). */ const movedRows: Array<{ row: HTMLElement; anchor: Comment }> = []; -/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. */ +/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. + * 글 칸(첫 글 마디)만 고침 — 이름표에 붙은 [삭제] 단추는 그대로(294-1). */ function setGroupLabel(group: HTMLElement, suffix: string): void { const legend = group.querySelector("legend"); if (!legend || legend.dataset.fixedLabel) return; - legend.dataset.baseLabel ??= legend.textContent ?? ""; + const text = + [...legend.childNodes].find((node) => node.nodeType === Node.TEXT_NODE) ?? + legend.insertBefore(document.createTextNode(""), legend.firstChild); + legend.dataset.baseLabel ??= text.textContent ?? ""; const base = legend.dataset.baseLabel; - legend.textContent = suffix ? `${base} (${suffix})` : base; + text.textContent = suffix ? `${base} (${suffix})` : base; } /** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */ From 704e193c1bd6c7835e68d905c6b140fcbca75f0a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:45:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(b06):=20=EB=AC=BC=EB=84=98=EC=9D=B4?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8=20?= =?UTF-8?q?=ED=98=95=EC=83=81=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=3D=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=EA=B0=81=EB=8F=84=E3=80=8D=20=EC=83=81=EC=9E=90=20(29?= =?UTF-8?q?4-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 형상 누름 키 "pipe" 가 배관 명세 「관」 상자를 찾다 물넘이엔 없어 강조가 안 섬 → 관 상자가 없거나 숨었으면 보이는 「경사각도」(slope) 상자 - 배관은 「관」 상자 그대로 · 알약 · 목록 · 카드 고름 · 다시 누름 = 풂 그대로 - 시험 test_294_1_chute_remove_pick(가짜 DOM 위 조정 강조 — [삭제] 단추 · 원래 글 · 경사각도 상자) 새로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 16 +- .../tester/helper_294_1_chute_remove_pick.cjs | 271 ++++++++++++++++++ .../tester/test_294_1_chute_remove_pick.py | 68 +++++ 3 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_294_1_chute_remove_pick.cjs create mode 100644 resources/tester/test_294_1_chute_remove_pick.py diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 394bfae5d..8200b71a0 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -51,6 +51,13 @@ const EXTRA_ROWS = [ const CHUTE_BOX = 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]'; +/** 물넘이포장 「경사각도」 상자 — 콘크리트 형상 고름이 켬(294-2 · 「관」 상자가 없는 시설). */ +const SLOPE_BOX = 'fieldset[data-spec-container="slope"]'; + +/** 숨지 않은(지금 보이는 명세의) 상자 하나. */ +const visibleBox = (selector: string): HTMLElement | undefined => + [...document.querySelectorAll(selector)].find((box) => !box.closest("[hidden]")); + /** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */ const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height"; @@ -167,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { clearSideSlots(); const side = panelRoot.dataset.side; // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 - // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) + // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) · 물넘이포장 콘크리트 = 「경사각도」 상자(294-2) // 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4) if (side === "body" || side === "pipe" || side === "chute") { - const body = + const own = side === "chute" - ? [...document.querySelectorAll(CHUTE_BOX)].find( - (box) => !box.closest("[hidden]"), - ) + ? visibleBox(CHUTE_BOX) : (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) ?.closest(".b05-structure__spec") ?.querySelector( @@ -182,6 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { ? 'fieldset[data-spec-container="pipe"]' : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', ); + const body = side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX) : own; if (!body || body.closest("[hidden]")) return; setGroupLabel(body, ""); body.classList.add("is-active"); diff --git a/resources/tester/helper_294_1_chute_remove_pick.cjs b/resources/tester/helper_294_1_chute_remove_pick.cjs new file mode 100644 index 000000000..e18dc83b0 --- /dev/null +++ b/resources/tester/helper_294_1_chute_remove_pick.cjs @@ -0,0 +1,271 @@ +/* PLAN 294-1 · 294-2 시험 헬퍼 — `B06_Section_UI_Adjust_Dock.ts` 를 트랜스파일해 작은 가짜 DOM 위에서 돌림. + * 294-1 — 강조 켜고 끈 뒤에도 이름표 [삭제] 단추 · 원래 글 · 괄호 붙임도 글 칸만. + * 294-2 — 물넘이포장(배관 명세 숨음)에서 side "pipe" = 「경사각도」 상자 강조. + * 결과 = 표준출력 JSON 한 벌. ⚠ 확장자 `.cjs` 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +// ── 작은 가짜 DOM — 태그 · 클래스 · 속성 · 자식 · 단순 선택자(쉼표 · `>` 하나) ────────── +const TEXT = 3; +class Text { + constructor(data) { + this.nodeType = TEXT; + this.textContent = data; + this.parentNode = null; + } +} +class Comment { + constructor() { + this.nodeType = 8; + this.parentNode = null; + } +} +const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +class El { + constructor(tag) { + this.nodeType = 1; + this.tagName = tag.toUpperCase(); + this.attrs = {}; + this.childNodes = []; + this.parentNode = null; + this.hidden = false; + const self = this; + this.dataset = new Proxy( + {}, + { + get: (_, key) => self.attrs[`data-${kebab(String(key))}`], + set: (_, key, value) => ((self.attrs[`data-${kebab(String(key))}`] = String(value)), true), + deleteProperty: (_, key) => delete self.attrs[`data-${kebab(String(key))}`], + }, + ); + this.classList = { + set: new Set(), + add: (...n) => n.forEach((c) => self.classList.set.add(c)), + remove: (...n) => n.forEach((c) => self.classList.set.delete(c)), + contains: (c) => self.classList.set.has(c), + toggle: (c, on) => + (on ?? !self.classList.set.has(c)) ? self.classList.add(c) : self.classList.remove(c), + }; + } + set className(v) { + this.classList.set = new Set(String(v).split(/\s+/).filter(Boolean)); + } + get children() { + return this.childNodes.filter((n) => n instanceof El); + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get firstElementChild() { + return this.children[0] ?? null; + } + get parentElement() { + return this.parentNode instanceof El ? this.parentNode : null; + } + get textContent() { + return this.childNodes.map((n) => (n.nodeType === 8 ? "" : n.textContent)).join(""); + } + set textContent(v) { + this.childNodes.forEach((n) => (n.parentNode = null)); + this.childNodes = []; + this.append(String(v)); + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k === "hidden" ? this.hidden : k in this.attrs; + } + append(...kids) { + for (const k of kids) { + const node = typeof k === "string" ? new Text(k) : k; + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + this.childNodes.push(node); + } + } + after(node) { + const list = this.parentNode.childNodes; + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this.parentNode; + list.splice(list.indexOf(this) + 1, 0, node); + } + insertBefore(node, ref) { + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + const at = ref ? this.childNodes.indexOf(ref) : -1; + if (at < 0) this.childNodes.push(node); + else this.childNodes.splice(at, 0, node); + return node; + } + replaceChild(node, old) { + const at = this.childNodes.indexOf(old); + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + this.childNodes[at] = node; + old.parentNode = null; + } + replaceChildren(...kids) { + this.childNodes.forEach((n) => (n.parentNode = null)); + this.childNodes = []; + this.append(...kids); + } + scrollIntoView() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + matches(selector) { + return selector.split(",").some((one) => { + const parts = one.trim().split(/\s*>\s*/); + if (parts.length === 2) + return ( + matchOne(this, parts[1]) && !!this.parentElement && matchOne(this.parentElement, parts[0]) + ); + return matchOne(this, parts[0]); + }); + } + closest(selector) { + for (let at = this; at instanceof El; at = at.parentElement) + if (at.matches(selector)) return at; + return null; + } + querySelectorAll(selector) { + return this.all().filter((n) => n.matches(selector)); + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} +function matchOne(el, compound) { + const tag = compound.match(/^[a-z]+/i)?.[0]; + if (tag && el.tagName !== tag.toUpperCase()) return false; + for (const [, c] of compound.matchAll(/\.([\w-]+)/g)) if (!el.classList.contains(c)) return false; + for (const [, k, v] of compound.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) { + if (k === "hidden") { + if (!el.hidden) return false; + } else if (v === undefined ? !(k in el.attrs) : el.attrs[k] !== v) return false; + } + return true; +} +const body = new El("body"); +globalThis.Node = { TEXT_NODE: TEXT }; +globalThis.document = { + createElement: (tag) => new El(tag), + createTextNode: (data) => new Text(data), + createComment: () => new Comment(), + querySelectorAll: (s) => body.querySelectorAll(s), + querySelector: (s) => body.querySelector(s), +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +} +const dock = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Adjust_Dock.ts")); + +// ── 폼 흉내 — 명세 상자 = fieldset[data-spec-container] > legend(글 + [삭제]) ────────── +const el = (tag, attrs = {}, ...kids) => { + const node = new El(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "hidden") node.hidden = v; + else node.setAttribute(k, v); + } + node.append(...kids); + return node; +}; +const removeButton = () => el("button", { "data-spec-remove": "chute" }, "삭제"); +const box = (id, title, withRemove, ...kids) => { + const legend = el("legend", {}, title); + if (withRemove) legend.append(" ", removeButton()); + return el("fieldset", { "data-spec-container": id }, legend, ...kids); +}; +const slots = { + inlet: el("div"), + outlet: el("div"), + extra: el("div"), + revetInlet: el("div"), + revetOutlet: el("div"), + wingInlet: el("div"), + wingOutlet: el("div"), +}; +// 배관 명세(숨음 · 물넘이포장 고른 때) — 「관」 · 유입 · 유출 칸 +const pipeSpec = el( + "div", + { class: "b05-structure__spec", hidden: true }, + box("pipe", "관", false), + box("inlet", "유입구", false, slots.inlet), + box("outlet", "유출구", false, slots.outlet), + box("revet_in", "기슭막이 유입", false, slots.revetInlet), + box("revet_out", "기슭막이 유출", false, slots.revetOutlet), + box("extra", "추가", false, slots.extra), +); +// 보이는 명세 — 날개벽 유출 칸(괄호 붙임 + [삭제] 단추 상자) · 물넘이포장 경사각도 · 도수로/돌붙임 +const wingOut = box("wing_out", "유출 날개벽", true, slots.wingOutlet); +const ford = el( + "div", + { class: "b05-structure__spec" }, + box("wing_in", "유입 날개벽", false, slots.wingInlet), + wingOut, + box("slope", "경사각도", false), + box("chute", "도수로/돌붙임", true), +); +body.append(pipeSpec, ford); +dock.setAdjustSideSlots(slots); + +const legendOf = (sel) => { + const fs_ = body.querySelector(sel); + const legend = fs_.querySelector("legend"); + return { + active: fs_.classList.contains("is-active"), + text: legend.childNodes.find((n) => n.nodeType === TEXT)?.textContent, + buttons: legend.children.filter((c) => c.tagName === "BUTTON").map((c) => c.textContent), + base: legend.dataset.baseLabel ?? null, + }; +}; +const CHUTE = 'fieldset[data-spec-container="chute"]'; +const SLOPE = 'fieldset[data-spec-container="slope"]'; +const WING = 'fieldset[data-spec-container="wing_out"]'; +const PIPE = 'fieldset[data-spec-container="pipe"]'; + +const panel = el("div"); +const out = {}; +// 294-1 ① 도수로 띠 고름 → 상자 강조 · 단추 그대로 +panel.dataset.side = "chute"; +dock.adoptAdjustPanel(panel); +out.chuteOn = legendOf(CHUTE); +// ② 날개벽 유출 고름 — 괄호 붙임(글 칸만) · 단추 그대로 · 도수로 강조 풂 +panel.dataset.side = "outlet"; +panel.dataset.panelTitle = "기슭막이"; +dock.refreshAdjustSlots(); +out.wingOn = legendOf(WING); +out.chuteOff = legendOf(CHUTE); +// 294-2 ③ 물넘이포장 콘크리트(side "pipe" · 관 상자 숨음) = 경사각도 상자 +panel.dataset.side = "pipe"; +dock.refreshAdjustSlots(); +out.slopeOn = legendOf(SLOPE); +out.pipeOn = legendOf(PIPE).active; +out.wingOff = legendOf(WING); +// ④ 풂 — 모두 원래 글 · 단추 +panel.dataset.side = ""; +dock.refreshAdjustSlots(); +out.released = [CHUTE, WING, SLOPE].map((sel) => legendOf(sel)); +// ⑤ 배관을 고른 때(배관 명세 보임 · 물넘이 숨음)는 그대로 「관」 상자 +pipeSpec.hidden = false; +ford.hidden = true; +panel.dataset.side = "pipe"; +dock.refreshAdjustSlots(); +out.pipeVisible = { pipe: legendOf(PIPE).active, slope: legendOf(SLOPE).active }; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_294_1_chute_remove_pick.py b/resources/tester/test_294_1_chute_remove_pick.py new file mode 100644 index 000000000..558242756 --- /dev/null +++ b/resources/tester/test_294_1_chute_remove_pick.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""PLAN 294-1 · 294-2 — 선택 컨테이너 [삭제] 단추 · 물넘이 콘크리트 형상 고름 컨테이너. + +294-1 B06 조정 강조(`setGroupLabel`)가 이름표 글 칸만 고침 — 강조 켜고 끈 뒤에도 [삭제] 단추 · + 원래 글(「삭제」 안 섞임) · 괄호 붙임도 글 칸만. +294-2 물넘이포장 콘크리트 누름(side "pipe" · 배관 명세 숨음) = 「경사각도」 상자 강조 · 배관이면 「관」 상자 그대로. +Node 가짜 DOM 위에서 `B06_Section_UI_Adjust_Dock.ts` 를 돌림(`helper_294_1_chute_remove_pick.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_294_1_chute_remove_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _box(text, buttons, active, base=None): + return {"active": active, "text": text, "buttons": buttons, "base": base or text} + + +def test_도수로_강조_켜도_삭제_단추_원래_글(out): + assert out["chuteOn"] == _box("도수로/돌붙임", ["삭제"], True) + + +def test_괄호_붙임도_글_칸만_단추_그대로(out): + assert out["wingOn"] == _box("유출 날개벽 (기슭막이)", ["삭제"], True, "유출 날개벽") + assert out["chuteOff"] == _box("도수로/돌붙임", ["삭제"], False) + + +def test_물넘이_콘크리트_고름_경사각도_상자(out): + assert out["slopeOn"] == _box("경사각도", [], True) + assert out["pipeOn"] is False + assert out["wingOff"] == _box("유출 날개벽", ["삭제"], False) + + +def test_풀면_원래_글_단추_그대로(out): + assert out["released"] == [ + _box("도수로/돌붙임", ["삭제"], False), + _box("유출 날개벽", ["삭제"], False), + _box("경사각도", [], False), + ] + + +def test_배관이면_관_상자_그대로(out): + assert out["pipeVisible"] == {"pipe": True, "slope": False} + + +def test_형상_고름_길_그대로(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + # 물넘이 콘크리트 · 맹암거 형상 누름 = 관 지점 고름 키 "pipe"(287-5) · 다시 누름 = 풂(toggle) + assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view + assert 'setShapeActive(key === "pipe");' in view