fix(b06): 물넘이포장 콘크리트 형상 고름 · 강조 = 좌측 「경사각도」 상자 (294-2)
- 형상 누름 키 "pipe" 가 배관 명세 「관」 상자를 찾다 물넘이엔 없어 강조가 안 섬 → 관 상자가 없거나 숨었으면 보이는 「경사각도」(slope) 상자 - 배관은 「관」 상자 그대로 · 알약 · 목록 · 카드 고름 · 다시 누름 = 풂 그대로 - 시험 test_294_1_chute_remove_pick(가짜 DOM 위 조정 강조 — [삭제] 단추 · 원래 글 · 경사각도 상자) 새로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
@@ -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<HTMLElement>(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<HTMLElement>(CHUTE_BOX)].find(
|
||||
(box) => !box.closest("[hidden]"),
|
||||
)
|
||||
? visibleBox(CHUTE_BOX)
|
||||
: (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet)
|
||||
?.closest(".b05-structure__spec")
|
||||
?.querySelector<HTMLElement>(
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user