diff --git a/A00_Common/design/design_dock.css b/A00_Common/design/design_dock.css new file mode 100644 index 00000000..dcfcf207 --- /dev/null +++ b/A00_Common/design/design_dock.css @@ -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; +} diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index bf39f8e8..d330bf54 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -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"; diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs new file mode 100644 index 00000000..20da79ff --- /dev/null +++ b/resources/tester/helper_design_view_parity.cjs @@ -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}`; +} + +// ── 바깥 모듈 대역 ───────────────────────────────────────────────────────────── +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 })); diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py new file mode 100644 index 00000000..0e6182a5 --- /dev/null +++ b/resources/tester/test_design_view_parity.py @@ -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