test(B05·B06): 좌측 패널 같음 지킴 시험 · 아래 단추 한 줄 배치(24-2f)

- test_design_view_parity.py 10건 — 두 보기 공용 칸 DOM · 단추 넷 차례 · 보기 전환 글자 · 페이지 파일이 좌측 칸 · 단추를 직접 만들면 빨강
- 아래 단추 글자가 두 줄로 꺾이지 않게 design_dock.css

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
2026-09-29 20:41:02 +09:00
co-authored by Claude Sonnet 5.5
parent 2c520537b6
commit 7d92298b03
4 changed files with 386 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가
두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */
.b05-route__dock .b05-route__actions .ui-btn,
.b05-route__dock .b06-profile__actions .ui-btn {
padding-inline: var(--spacing-8);
white-space: nowrap;
}
+1
View File
@@ -10,6 +10,7 @@
import { createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import "./design_dock.css";
/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
export type DesignView = "profile" | "cross";
@@ -0,0 +1,240 @@
/* 두 보기 좌측 패널 **같음 지킴** 헬퍼(계획서 24-2f) — 공용 등록부(`design_panel` · `design_dock` ·
* `design_sections`)와 종단 보기 칸 정의를 트랜스파일해 가짜 DOM 위에 두 보기를 같은 견본으로 세운다.
* 결과는 표준출력에 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, "..", "..");
const cache = new Map();
// ── 화면 그릇 흉내 — 요소 · 클래스 · 속성 · 글자 · 자식 ──────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.parent = null;
this.dataset = {};
this.style = {};
this.attrs = {};
this._text = "";
this._classes = new Set();
this.hidden = false;
this.disabled = false;
this.title = "";
this.listeners = {};
const self = this;
this.classList = {
add: (...n) => n.forEach((x) => self._classes.add(x)),
remove: (...n) => n.forEach((x) => self._classes.delete(x)),
toggle: (n, f) => {
const on = f === undefined ? !self._classes.has(n) : !!f;
on ? self._classes.add(n) : self._classes.delete(n);
return on;
},
contains: (n) => self._classes.has(n),
};
}
set className(v) {
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(v) {
this._text = String(v);
this.children.forEach((c) => (c.parent = null));
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
_adopt(kid) {
const node =
typeof kid === "string" ? Object.assign(new FakeNode("#text"), { _text: kid }) : kid;
if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node);
node.parent = this;
return node;
}
append(...kids) {
kids.forEach((k) => this.children.push(this._adopt(k)));
}
prepend(...kids) {
this.children.unshift(...kids.map((k) => this._adopt(k)));
}
insertBefore(node, ref) {
const adopted = this._adopt(node);
const at = this.children.indexOf(ref);
if (at < 0) this.children.push(adopted);
else this.children.splice(at, 0, adopted);
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
removeAttribute(k) {
delete this.attrs[k];
if (k === "title") this.title = "";
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
if (this.disabled) return;
(this.listeners.click || []).forEach((h) => h({ target: this }));
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
querySelectorAll(selector) {
const cls = selector.slice(1);
const out = [];
const walk = (n) =>
n.children.forEach((k) => {
if (k.classList && k.classList.contains(cls)) out.push(k);
walk(k);
});
walk(this);
return out;
}
}
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
function html(node) {
if (node.tagName === "#text") return node._text;
const cls = node.className ? ` class="${node.className}"` : "";
const attrs = Object.entries(node.attrs)
.sort()
.map(([k, v]) => ` ${k}="${v}"`)
.join("");
const hidden = node.hidden ? " hidden" : "";
const off = node.disabled ? " disabled" : "";
const inner = node._text + node.children.map(html).join("");
return `<${node.tagName}${cls}${attrs}${hidden}${off}>${inner}</${node.tagName}>`;
}
// ── 바깥 모듈 대역 ─────────────────────────────────────────────────────────────
const LOCALES = {
Common_Btn_Reset: ["초기화", "Reset"],
B06_Profile_Btn_Save: ["저장", "Save"],
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
};
const STUBS = {
"@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 },
"@ui/ui_template_collapsible": { attachCollapsible: () => undefined },
"@ui/ui_template_elements": {
createButton: ({ label, variant, onClick }) => {
const button = new FakeNode("button");
button.className = `ui-btn ui-btn--${variant ?? "ghost"}`;
const span = new FakeNode("span");
span.className = "ui-btn__label";
span.textContent = label;
button.append(span);
button.addEventListener("click", () => onClick && onClick());
return button;
},
},
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const panel = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts"));
loadTs(path.join(ROOT, "A00_Common", "design", "design_sections.ts"));
loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts"));
// ── 같은 견본 — 두 보기가 **같은 구조물 배치 부품**을 받는다 ─────────────────────
function sample() {
const root = new FakeNode("section");
root.className =
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section";
const title = new FakeNode("h3");
title.className = "ui-collapsible__title";
title.textContent = "구조물 배치";
root.append(title);
const listRoot = new FakeNode("div");
listRoot.className = "b05-structure__list";
listRoot.textContent = "구조물 목록";
return { root, listRoot };
}
const text = (t) => Object.assign(new FakeNode("div"), { _text: t });
function context(view, calls) {
const run = (name) => () => calls.push(`${view}:${name}`);
return {
projectId: "p",
parts: {
structures: sample(),
settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
calcNodes: [text("calc")],
selectedNodes: [text("sel")],
bindSelectedRoot: (root) => {
root.hidden = true;
},
},
},
dock: {
reset: { run: run("reset") },
save: { run: run("save") },
confirm: { run: run("confirm"), enabled: view === "cross" },
switchView: { run: run("switch") },
},
};
}
const calls = [];
const views = {};
for (const view of ["profile", "cross"]) {
const built = panel.createDesignPanel(context(view, calls), view);
const dockRoot = built.dock.root;
const buttons = dockRoot.querySelectorAll(".ui-btn");
buttons.forEach((b) => b.click());
const structures = built.root.children.find((c) => c.classList.contains("b05-structure-section"));
views[view] = {
rootClass: built.root.className,
sectionIds: panel.panelSectionsFor(view).map((d) => d.id),
structuresHtml: html(structures),
structuresFirst: built.root.children[0] === structures,
dockFirstChild: html(dockRoot.children[0]),
dockClass: dockRoot.className,
dockLabels: buttons.map((b) => b.textContent),
dockActions: buttons.map((b) => b.dataset.dockAction),
dockDisabled: buttons.map((b) => b.disabled),
dockRowClass: dockRoot.children[dockRoot.children.length - 1].className,
dockIsLast: built.root.children[built.root.children.length - 1] === dockRoot,
};
}
// 보기 전환 — 글자가 따라 바뀐다.
const switched = panel.createDesignPanel(context("profile", []), "profile");
switched.setView("cross");
const switchLabelAfter = switched.dock.root
.querySelectorAll(".ui-btn")
.map((b) => b.textContent)
.slice(-1)[0];
process.stdout.write(JSON.stringify({ views, calls, switchLabelAfter }));
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
"""B05 · B06 한 페이지 두 보기 — **좌측 패널 같음 지킴**(계획서 24-2f).
① 두 보기의 좌측 패널을 같은 견본으로 세워 공용 칸(구조물 배치)의 DOM · 아래 고정 영역이 같은지 본다
(Node 헬퍼 `helper_design_view_parity.cjs` 가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다).
② 페이지 파일(`B05_Profile_UI_Page.ts` · `B06_Section_UI_Page.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를
직접 만들면 빨강 — 칸과 단추는 등록부(`A00_Common/design/`)만 만든다.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
PAGE_FILES = (
ROOT / "B05_Profile" / "B05_Profile_UI_Page.ts",
ROOT / "B06_Section" / "B06_Section_UI_Page.ts",
)
# 페이지 파일에 나오면 안 되는 것 — 좌측 칸 · 아래 단추 · 저장 단추를 직접 만든다는 표시.
FORBIDDEN_IN_PAGES = (
"createButton(",
"attachCollapsible(",
"buildGroup(",
"createSectionFrame(",
"registerPanelSection(",
"createDesignDock(",
"ui-sidebar-actions",
"b05-route__dock",
"b05-route__actions",
"b06-profile__actions",
"b06-profile__form",
"b05-route__panel",
"b06-profile__group",
"saveButton",
"confirmButton",
"actionDock",
)
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_design_view_parity.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_공용_칸_구조물_배치는_두_보기가_같은_DOM(results):
profile, cross = results["views"]["profile"], results["views"]["cross"]
assert profile["structuresHtml"] == cross["structuresHtml"]
assert profile["structuresFirst"] and cross["structuresFirst"] # 맨 위 칸
def test_구조물_목록은_두_보기_모두_아래_고정_영역_맨_위(results):
profile, cross = results["views"]["profile"], results["views"]["cross"]
assert profile["dockFirstChild"] == cross["dockFirstChild"]
assert profile["dockClass"] == cross["dockClass"]
assert profile["dockIsLast"] and cross["dockIsLast"]
def test_아래_단추는_네_개_두_보기_같은_차례(results):
profile, cross = results["views"]["profile"], results["views"]["cross"]
assert (
profile["dockActions"]
== cross["dockActions"]
== [
"reset",
"save",
"confirm",
"switchView",
]
)
assert profile["dockLabels"][:3] == cross["dockLabels"][:3] == ["초기화", "저장", "확정"]
def test_보기_전환_단추는_가려는_쪽_이름(results):
assert results["views"]["profile"]["dockLabels"][3] == "횡단 보기"
assert results["views"]["cross"]["dockLabels"][3] == "종단 보기"
assert results["switchLabelAfter"] == "종단 보기" # setView("cross") 뒤
def test_꺼진_단추는_안_눌리고_켜진_단추는_동작을_부름(results):
assert results["views"]["profile"]["dockDisabled"] == [False, False, True, False]
assert results["views"]["cross"]["dockDisabled"] == [False, False, False, False]
assert results["calls"] == [
"profile:reset",
"profile:save",
"profile:switch",
"cross:reset",
"cross:save",
"cross:confirm",
"cross:switch",
]
def test_칸_차례는_보기마다_등록부_순서(results):
assert results["views"]["profile"]["sectionIds"] == [
"structures",
"page-settings",
"planned-route",
"route-calc",
"selected-point",
]
def test_횡단_보기_칸_정의와_차례():
"""B06 칸 정의는 화면 부품(입력 칸 · 표준 패널)을 물어 Node 에서 못 읽는다 — 소스로 본다."""
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Panel.ts").read_text(encoding="utf-8")
found = re.findall(
r'id: "([\w-]+)",\s*title:[^\n]*\n\s*views: \["cross"\],\s*order: (\d+)', source
)
ordered = [name for name, _ in sorted(found, key=lambda item: int(item[1]))]
assert ordered == ["cross-view", "standard-cross"]
shared = (ROOT / "A00_Common" / "design" / "design_sections.ts").read_text(encoding="utf-8")
assert 'views: ["profile", "cross"]' in shared # 구조물 배치는 한 정의로 두 보기
@pytest.mark.parametrize("page", PAGE_FILES, ids=lambda path: path.name)
def test_페이지_파일이_좌측_칸_단추_저장을_직접_만들지_않는다(page):
text = page.read_text(encoding="utf-8")
found = [marker for marker in FORBIDDEN_IN_PAGES if marker in text]
assert not found, f"{page.name} 이(가) 좌측 칸 · 단추를 직접 만든다: {found}"
def test_두_보기_패널은_같은_등록부로_세운다():
b05 = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts").read_text(encoding="utf-8")
b06 = (ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8")
assert "createDesignPanel(" in b05 and '"profile"' in b05
assert "createDesignPanel(" in b06 and '"cross"' in b06