Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-12 21:47:13 +09:00
3 changed files with 357 additions and 8 deletions
+18 -8
View File
@@ -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";
@@ -101,13 +108,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<HTMLElement>("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;
}
/** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */
@@ -163,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>(
@@ -178,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