feat(481-1 페이지 이동): B05~B09 좌측 아래 [?] [<] [>] — 이웃 진행 단계 단추 누름 · B05 · B06 [노선 보기] · [횡단 보기] 지움

- ui_template_help: [?] 뒤 [<] [>] · 다섯 주소 목록만(b09-print · B03 · B04 · M01 · M02 뺌) · B09 [>] 흐림 · ui-step-nav 이벤트
- ui_template_workflow_layout: ui-step-nav 듣개 — 켜진 칸 ±1 단계 단추 click(onStepClick 한 길) · 꺼진 단계 = 그 단추 title 알림
- CSS: [<] [>] = [?] 같은 정사각 · 늘어남 규칙에서 뺌(overlay · B09 도크 한 줄)
- design_dock · design_shared · locale_b2: switchView 단추 · 글 지움
- 시험: test_280_2_step_nav(새) · test_design_view_parity(단추 셋)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:05:26 +09:00
co-authored by Claude Opus 5.5
parent afe6e4a799
commit d593075ea5
12 changed files with 386 additions and 52 deletions
+246
View File
@@ -0,0 +1,246 @@
/* 요구 280-2 — 좌측 아래 [?] [<] [>] 헬퍼. `ui_template_help.ts` · `ui_template_workflow_layout.ts` 를
* 트랜스파일해 가짜 DOM 위에서 ① 주소마다 줄 차례 ② [<] [>] = 이웃 단계 단추 누름(꺼진 단계 = 알림만)을
* 돌려 JSON 한 벌로 냄. 확장자 `.cjs` 까닭은 `helper_design_view_parity.cjs` 머리와 같음.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
class FakeNode {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.parent = null;
this.dataset = {};
this.listeners = {};
this.disabled = false;
this.title = "";
this._text = "";
this._classes = new Set();
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) =>
(f === undefined ? !self._classes.has(n) : !!f)
? self._classes.add(n)
: self._classes.delete(n),
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 = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
_adopt(kid) {
if (kid.parent) kid.parent.children = kid.parent.children.filter((c) => c !== kid);
kid.parent = this;
return kid;
}
append(...kids) {
kids.forEach((k) => this.children.push(this._adopt(k)));
}
prepend(...kids) {
this.children.unshift(...kids.map((k) => this._adopt(k)));
}
setAttribute() {}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
dispatchEvent(event) {
for (let node = this; node; node = event.bubbles ? node.parent : null)
(node.listeners[event.type] || []).forEach((h) => h(event));
}
click() {
if (!this.disabled) this.dispatchEvent({ type: "click", bubbles: true, target: this });
}
_matches(selector) {
if (selector === "[data-help-row]") return "helpRow" in this.dataset;
const [, cls, not] = /^\.([\w-]+)(?::not\(\.([\w-]+)\))?$/.exec(selector);
return this._classes.has(cls) && !(not && this._classes.has(not));
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
querySelectorAll(selector) {
const out = [];
const walk = (n) => n.children.forEach((k) => (k._matches(selector) && out.push(k), walk(k)));
walk(this);
return out;
}
}
class CustomEvent {
constructor(type, init) {
Object.assign(this, { type, bubbles: !!init?.bubbles, detail: init?.detail });
}
}
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
globalThis.location = { hash: "" };
globalThis.CustomEvent = CustomEvent;
globalThis.HTMLButtonElement = FakeNode;
// ── 바깥 모듈 대역 ─────────────────────────────────────────────────────────────
const ROUTES = {
B01_ACCOUNT: "b01-account",
B03_FILE_INPUT: "b03-file-input",
B04_PREPROCESS: "b04-preprocess",
B05_PROFILE: "b05-profile",
B06_SECTION: "b06-section",
B07_DESIGN_DETAIL: "b07-design-detail",
B08_QUANTITY: "b08-quantity",
B09_ESTIMATION: "b09-estimation",
};
const toasts = [];
let role = "SYSTEM_ADMIN";
let help;
const STUBS = {
"@config/config_frontend": { ROUTES, API_BASE_URL: "" },
"./ui_template_locale": { t: (key) => key },
"./ui_template_modal": { openModal: () => undefined },
"./ui_template_elements": {
showToast: (message) => toasts.push(message),
el: (tag) => new FakeNode(tag),
createButton: ({ label, onClick }) => {
const button = new FakeNode("button");
button.className = "ui-btn";
button.textContent = label;
button.addEventListener("click", () => onClick && onClick());
return button;
},
},
// 진짜 겹판은 `splitSidebarActions` → `placeHelpButton` — 같은 차례로 흉내
"./ui_template_overlay": {
createWorkflowOverlays: ({ optionsContent, progressContent }) => {
const root = new FakeNode("div");
root.append(optionsContent, progressContent);
const actions = optionsContent._classes.has("ui-sidebar-actions")
? optionsContent
: optionsContent.querySelector(".ui-sidebar-actions");
help.placeHelpButton(actions);
return { root, setTitleOpen() {}, setProgressOpen() {} };
},
},
"../A00_Common/router": { navigateTo: () => undefined },
"../A00_Common/app_shell": { setDrawerSteps: () => undefined },
"../B01_Dashboard/B01_Dashboard_Api_Fetch": { fetchDashboardMe: async () => ({ role }) },
};
globalThis.__DEV = false;
function loadTs(file) {
const src = fs.readFileSync(file, "utf8").replace(/import\.meta\.env\.DEV/g, "globalThis.__DEV");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (request.endsWith(".css")) return {};
if (STUBS[request]) return STUBS[request];
throw new Error(`대역 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
help = loadTs(path.join(ROOT, "ui_template", "ui_template_help.ts"));
const layout = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts"));
// ① 주소마다 줄 차례 — [data-help-row] 있는 줄 · 없는 줄(B08 · B09 꼴)
const rowOf = (route, withHelpRow) => {
location.hash = `#/${route}`;
const actions = new FakeNode("div");
actions.className = "ui-sidebar-actions";
const row = new FakeNode("div");
if (withHelpRow) {
row.dataset.helpRow = "";
actions.append(row);
}
const save = new FakeNode("button");
save.className = "ui-btn";
save.textContent = "저장";
(withHelpRow ? row : actions).append(save);
help.placeHelpButton(actions);
help.placeHelpButton(actions); // 두 번 불러도 한 벌
const kids = (withHelpRow ? row : actions).children;
return { labels: kids.map((k) => k.textContent), disabled: kids.map((k) => k.disabled) };
};
const ALL = [
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
"b09-print",
"m01-master-data",
"m02-master-template",
];
const rows = {};
for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: rowOf(route, false) };
// ② [<] [>] = 이웃 단계 누름
const STEP_ROUTES = ALL.slice(0, 7);
function page(route, { stages, dev = false, module = layout } = {}) {
globalThis.__DEV = dev;
location.hash = `#/${route}`;
const calls = [];
const handle = module.createWorkflowLayout({
title: route,
steps: STEP_ROUTES,
activeStep: STEP_ROUTES.indexOf(route),
mainContent: new FakeNode("div"),
routes: STEP_ROUTES,
stages,
currentStage: STEP_ROUTES.indexOf(route),
onStepClick: (index, to) => calls.push(`${index}:${to}`),
});
const press = (step) => {
toasts.length = 0;
const button = handle.root
.querySelectorAll(".ui-step-nav-btn")
.find((b) => b.dataset.stepNav === String(step));
button.click();
return { calls: calls.splice(0), toasts: [...toasts] };
};
return { handle, press };
}
(async () => {
const nav = {};
const b05 = page("b05-profile");
nav.b05Prev = b05.press(-1);
nav.b05Next = b05.press(1);
// B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep)
location.hash = "#/b06-section";
b05.handle.setActiveStep(3);
nav.b06Prev = b05.press(-1);
nav.b06Next = b05.press(1);
const b09 = page("b09-estimation");
nav.b09Prev = b09.press(-1);
nav.b09Next = b09.press(1);
// 운영 — 막힌 단계(NOT_STARTED) = 누르지 않고 그 단추 title 알림
const stages = STEP_ROUTES.map((_, i) => ({
stage_no: i,
stage_key: String(i),
state: i >= 4 ? "NOT_STARTED" : "COMPLETE",
}));
const blocked = page("b06-section", { stages });
nav.blockedNext = blocked.press(1);
// 일반 사용자 — B05 [<](B04) = 관리자 전용 알림(역할은 모듈이 한 번 물어 기억 → 새로 읽음)
role = "USER";
const fresh = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts"));
const user = page("b05-profile", { dev: true, module: fresh });
await new Promise((r) => setTimeout(r, 0));
nav.userPrev = user.press(-1);
process.stdout.write(JSON.stringify({ rows, nav }));
})();
@@ -124,8 +124,6 @@ const LOCALES = {
Common_Btn_Reset: ["초기화", "Reset"],
B06_Profile_Btn_Save: ["저장", "Save"],
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
Design_Btn_ToProfile: ["노선 보기", "Route view"],
Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2)
};
const STUBS = {
@@ -206,7 +204,6 @@ const built = panel.createDesignPanel(
reset: { run: run("reset") },
save: { run: run("save") },
confirm: { run: run("confirm") },
switchView: { run: run("switch") },
},
},
"profile",
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""요구 280-2 · PLAN 481-1 — 좌측 아래 [?] [<] [>] 페이지 이동.
① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B09 [>] 흐림
② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계 = 누르지 않고 그 단추 title 알림
③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · B05 · B06 도크 = [초기화] [저장] [확정] 셋
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
FIVE = ("b05-profile", "b06-section", "b07-design-detail", "b08-quantity", "b09-estimation")
OTHERS = ("b03-file-input", "b04-preprocess", "b09-print", "m01-master-data", "m02-master-template")
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_280_2_step_nav.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("shape", ["helpRow", "plain"])
def test_다섯_주소만_물음_앞뒤_차례(results, shape):
for route in FIVE:
assert results["rows"][route][shape]["labels"] == ["?", "<", ">", "저장"], route
for route in OTHERS:
assert results["rows"][route][shape]["labels"] == ["?", "저장"], route
def test_B09_다음만_흐림(results):
for route in FIVE:
expected = [False, False, route == "b09-estimation", False]
assert results["rows"][route]["helpRow"]["disabled"] == expected, route
def test_앞뒤는_이웃_단계_누름(results):
nav = results["nav"]
assert nav["b05Prev"] == {"calls": ["1:b04-preprocess"], "toasts": []}
assert nav["b05Next"] == {"calls": ["3:b06-section"], "toasts": []}
# B05 → B06 은 같은 페이지 — setActiveStep 으로 켜진 칸이 바뀐 뒤 이웃
assert nav["b06Prev"] == {"calls": ["2:b05-profile"], "toasts": []}
assert nav["b06Next"] == {"calls": ["4:b07-design-detail"], "toasts": []}
assert nav["b09Prev"] == {"calls": ["5:b08-quantity"], "toasts": []}
assert nav["b09Next"] == {"calls": [], "toasts": []}
def test_막힌_단계는_알림만(results):
nav = results["nav"]
assert nav["blockedNext"] == {"calls": [], "toasts": ["WF_State_NotStarted"]}
assert nav["userPrev"] == {"calls": [], "toasts": ["시스템 관리자 전용 단계입니다."]}
def test_B05_B06_사이는_주소만_다시_안_실음():
page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8")
assert "if (designViewOf(route)) navigateTo(route);" in page
dock = (ROOT / "A00_Common" / "design" / "design_dock.ts").read_text(encoding="utf-8")
shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8")
for text in (dock, shared):
assert "switchView" not in text
assert "row.append(resetButton, saveButton, confirmButton);" in dock
locale = (ROOT / "ui_template" / "ui_template_locale_b2.ts").read_text(encoding="utf-8")
assert "Design_Btn_ToCross" not in locale and "Design_Btn_ToProfile" not in locale
+8 -10
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""B05 · B06 한 페이지 두 보기 — **좌측 패널 한 벌 지킴**(계획서 24-2f · 24-3b).
① 좌측 패널을 **하나** 세우고 보기를 오가도 뿌리 · 구조물 배치 · 아래 고정 영역 · 단추 넷이 같은 노드인지,
보기마다 제 칸만 보이는지, [보기 전환] 글자가 따라가는지 본다(Node 헬퍼 `helper_design_view_parity.cjs`
① 좌측 패널을 **하나** 세우고 보기를 오가도 뿌리 · 구조물 배치 · 아래 고정 영역 · 단추 셋이 같은 노드인지,
보기마다 제 칸만 보이는지 본다(Node 헬퍼 `helper_design_view_parity.cjs`
가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다).
② 보기 파일(`design_view_profile.ts` · `design_view_cross.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를
직접 만들면 빨강 — 칸 · 단추 · 저장은 한 벌(`design_shared`)만 만든다.
@@ -87,20 +87,18 @@ def test_보기마다_제_칸만_보인다(results):
assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"]
def test_아래_단추는_네_개_보기_전환_글자만_따라감(results):
def test_아래_단추는_세_개_보기_전환_없음(results):
"""[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2)"""
views = results["views"]
for name in ("profile", "cross", "back"):
assert views[name]["dockActions"] == ["reset", "save", "confirm", "switchView"]
assert views[name]["dockLabels"][:3] == ["초기화", "저장", "확정"]
assert views[name]["dockActions"] == ["reset", "save", "confirm"]
assert views[name]["dockLabels"] == ["초기화", "저장", "확정"]
# [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음).
assert views[name]["dockDisabled"] == [False, False, False, False]
assert views["profile"]["dockLabels"][3] == "횡단 보기"
assert views["cross"]["dockLabels"][3] == "노선 보기"
assert views["back"]["dockLabels"][3] == "횡단 보기"
assert views[name]["dockDisabled"] == [False, False, False]
def test_단추는_한_벌_동작을_부름(results):
names = ["reset", "save", "confirm", "switch"]
names = ["reset", "save", "confirm"]
assert results["calls"] == [
*(f"profile:{n}" for n in names),
*(f"cross:{n}" for n in names),