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:
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user