diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 4ee7d0a34..368f5c4a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -25,6 +25,7 @@ import { buildCrossLayoutControls, buildDesignInfoPanel, buildDrawingSidePanel, + foldedGroups, isCrossSheet, } from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -91,6 +92,8 @@ let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + // 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값) + const folded = foldedGroups(root); let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; @@ -565,6 +568,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } : undefined, crossLayoutSlot, + folded, ); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); @@ -608,7 +612,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); - // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. + // 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { const crossSheets = drawings.filter(isCrossSheet); @@ -618,7 +622,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const reopen = drawings.findIndex((item) => item.id === reopenId); reopenDrawingId = undefined; reopenCrossSheet = undefined; - const first = Math.max(reopen, 0); + // 311 기억한 장이 없으면 표지 + const cover = drawings.findIndex((item) => item.kind === "cover"); + const first = reopen >= 0 ? reopen : Math.max(cover, 0); void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index d19228411..a9eb48d9f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -39,6 +39,19 @@ export const DRAWING_GROUPS: readonly { { label: "토적도(유토곡선)", kind: "mass_haul" }, ]; +/** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */ +const FOLDED_KINDS = new Set(["plan", "longitudinal", "cross"]); + +/** 311 지금 목록의 묶음 접힘(kind → 접힘) — 화면을 다시 세울 때 이어 받음 · B07 목록이 없으면 빈 것. */ +export function foldedGroups(root: ParentNode): Map { + return new Map( + [...root.querySelectorAll(".b07-drawing-group")].map((section) => [ + section.dataset.kind ?? "", + section.classList.contains("is-collapsed"), + ]), + ); +} + /** B06 확정 산출물 기반 도면 목록 패널. */ export function buildDrawingSidePanel( drawings: DesignDrawingItem[], @@ -48,7 +61,10 @@ export function buildDrawingSidePanel( plan?: PlanManageControls, /** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */ crossTools?: HTMLElement, + /** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */ + folded = new Map(), ): HTMLDivElement { + const isFolded = (kind: string): boolean => folded.get(kind) ?? FOLDED_KINDS.has(kind); const panel = document.createElement("div"); panel.className = "b07-drawing-list"; const heading = document.createElement("div"); @@ -95,7 +111,12 @@ export function buildDrawingSidePanel( // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. const plans = drawings.filter((item) => item.kind === "plan"); panel.append( - buildPlanSection(plans, drawingButton, plan ? () => openPlanManager(plan) : undefined), + buildPlanSection( + plans, + drawingButton, + plan ? () => openPlanManager(plan) : undefined, + isFolded("plan"), + ), ); continue; } @@ -133,7 +154,7 @@ export function buildDrawingSidePanel( continue; } // 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식). - const section = createPanelCard(`${group.label} ${items.length}`); + const section = createPanelCard(`${group.label} ${items.length}`, isFolded(group.kind ?? "")); section.classList.add("b07-drawing-group"); if (group.kind) section.dataset.kind = group.kind; if (cross) { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 3d04ef741..d1f10c410 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -257,9 +257,10 @@ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, + collapsed = false, ): HTMLElement { // 컨테이너 = 좌측 패널 공용 카드(269) - const section = createPanelCard(`평면도 ${items.length}`); + const section = createPanelCard(`평면도 ${items.length}`, collapsed); section.classList.add("b07-drawing-group", "b07-plan"); section.dataset.kind = "plan"; diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs new file mode 100644 index 000000000..4b997e9bb --- /dev/null +++ b/resources/tester/helper_311_panel_folded.cjs @@ -0,0 +1,256 @@ +/* 311 좌측 패널 기본 접힘 헬퍼 — B05 「계획노선」 · B06 「횡단 보기 설정」(한 벌 패널 `design_panel`)과 + * B07 도면 목록(평면도 · 종단도 · 횡단도)을 가짜 DOM 위에 세워 첫 그림 접힘 · 편 뒤 다시 그림 유지를 본다. + * 결과는 JSON 한 벌. 꼴은 `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, "..", ".."); +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; + } +} +// 공용 attachCollapsible 이 쓰는 closest(`.클래스` 하나) +FakeNode.prototype.closest = function (selector) { + for (let n = this; n; n = n.parent) + if (n.classList && n.classList.contains(selector.slice(1))) return n; + return null; +}; +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; +/** 제목 누름 — 패널 뿌리에 걸린 공용 위임 손잡이로 올려 보냄(가짜 DOM 은 거품이 없음). */ +const press = (root, section) => { + const target = section.querySelector(".ui-collapsible__title"); + (root.listeners.click || []).forEach((h) => h({ target })); +}; + +const node = (tag = "div") => new FakeNode(tag); +const STUBS = { + "@ui/ui_template_locale": { + ui_locales: new Proxy({}, { get: (_, k) => [String(k)] }), + currentLanguageIndex: 0, + }, + "@ui/ui_template_elements": { + createButton: ({ label }) => Object.assign(node("button"), { _text: label }), + createInputField: () => ({ root: node(), input: node("input") }), + createSelectField: () => ({ root: node(), select: { value: "" } }), + showToast: () => undefined, + }, + "@ui/ui_template_checkbox": { createCheckbox: () => ({ root: node("label"), input: {} }) }, + "../B05_Profile/B05_Profile_UI_Structures_Fields": { stationFields: () => ({ wrap: node() }) }, + "./B07_DesignDetail_UI_PlanManage": { openPlanManager: () => undefined }, + "./B07_DesignDetail_Api_Fetch": {}, + "./B06_Section_UI_Page_Common": { L: (key) => key }, +}; + +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 === "@ui/ui_template_collapsible") + return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); + 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 out = {}; +const titleOf = (n) => n.children.find((c) => c.tagName === "h3")?.textContent ?? ""; +const folds = (root) => + Object.fromEntries( + root + .querySelectorAll(".ui-collapsible") + .map((s) => [titleOf(s), s.classList.contains("is-collapsed")]), + ); + +// ── B05 · B06 한 벌 패널 ─────────────────────────────────────────────────────── +const design = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts")); +loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts")); +loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Panel.ts")); +const text = (t) => Object.assign(node(), { _text: t }); +const run = { run: () => undefined }; +const panel = design.createDesignPanel( + { + projectId: "p", + parts: { + structures: { root: node("section"), listRoot: node() }, + settings: { nodes: [text("a")] }, + route: { + plannedRouteButton: text("plan"), + selectedNodes: [text("sel")], + bindSelectedRoot: () => undefined, + }, + crossView: { row: text("cross-row") }, + standardSlot: text("std"), + }, + dock: { reset: run, save: run, confirm: run, switchView: run }, + }, + "profile", +); +const byTitle = (title) => + panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title); +out.profileFirst = folds(panel.root); +const route = byTitle("계획노선"); +press(panel.root, route); // 사용자가 폄(공용 attachCollapsible) +panel.setView("cross"); +out.crossFirst = folds(panel.root); +press(panel.root, byTitle("B06_View_Title")); +panel.setView("profile"); +panel.setView("cross"); +out.afterRedraw = folds(panel.root); +out.sameRouteNode = byTitle("계획노선") === route; + +// ── B07 도면 목록 ───────────────────────────────────────────────────────────── +const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts")); +const d = (id, kind) => ({ id, kind, label: id, confirmed: false }); +const drawings = [ + d("longitudinal", "longitudinal"), + d("longitudinal_2", "longitudinal"), + d("cross_1", "cross"), + d("cross_2", "cross"), + d("plan_a", "plan"), + d("cover", "cover"), + d("cross_standard", "cross_standard"), + d("mass_haul", "mass_haul"), + d("mass_haul_2", "mass_haul"), +]; +const groups = (p) => + Object.fromEntries( + p + .querySelectorAll(".b07-drawing-group") + .map((s) => [s.dataset.kind, s.classList.contains("is-collapsed")]), + ); +const fresh = node(); +out.b07EntryFolded = [...b07.foldedGroups(fresh)]; +const first = b07.buildDrawingSidePanel( + drawings, + () => undefined, + undefined, + false, + undefined, + undefined, + b07.foldedGroups(fresh), +); +out.b07First = groups(first); +const host = node(); +host.append(first); +first + .querySelectorAll(".b07-drawing-group") + .find((s) => s.dataset.kind === "cross") + .classList.remove("is-collapsed"); +first + .querySelectorAll(".b07-drawing-group") + .find((s) => s.dataset.kind === "mass_haul") + .classList.add("is-collapsed"); +const again = b07.buildDrawingSidePanel( + drawings, + () => undefined, + undefined, + false, + undefined, + undefined, + b07.foldedGroups(host), +); +out.b07Redraw = groups(again); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py index 33ff60bd2..c2c5cc4b8 100644 --- a/resources/tester/test_269_panel_form.py +++ b/resources/tester/test_269_panel_form.py @@ -82,8 +82,8 @@ def test_공용_카드_함수(): def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴(): - assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS - assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN + assert "createPanelCard(`${group.label} ${items.length}`, isFolded(" in B07_PANELS + assert "createPanelCard(`평면도 ${items.length}`, collapsed)" in B07_PLAN for src in (B07_PANELS, B07_PLAN): assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음 assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2 @@ -149,7 +149,7 @@ def test_B05_칸_틀_두_보기_모두_공용_양식(): def test_B08_B09_카드_공용_카드(): assert "createPanelCard(L(title))" in B08_OPTIONS - assert 'createPanelCard("데이터 점검")' in B09_SHELL + assert 'createPanelCard("데이터 점검", true)' in B09_SHELL for src in (B08_OPTIONS, B09_SHELL): assert "ui-sidebar-card" not in src diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py new file mode 100644 index 000000000..66aeb65fd --- /dev/null +++ b/resources/tester/test_311_panel_folded.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""311 좌측 패널 기본 접힘 · B07 첫 쪽 = 표지 (요구 204). + +못박는 것 + - 기본 접힘 = B09 「데이터 점검」 · 「공기 옵션」 / B07 「평면도」 · 「종단도」 · 「횡단도」 / + B06 「횡단 보기 설정」 / B05 「계획노선」 · 다른 컨테이너 기본값 그대로. + - 쪽 안에서 편 것은 다시 그려도 그대로 — B05 · B06 = 한 벌 패널이 칸을 한 번만 세움 · B07 = 다시 세울 때 + 옛 목록 접힘을 이어 받음 · B09 = 좌측 패널을 쪽 그릴 때 한 번만 세움. + - B07 처음 들어가면 표지 — 고른 장 기억(모듈 값 `reopenDrawingId` · `reopenCrossSheet`)이 있으면 그 장. + +Node 헬퍼 `helper_311_panel_folded.cjs` 가 가짜 DOM 위에서 B05 · B06 · B07 패널을 돌림. +""" + +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] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_311_panel_folded.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_B05_B06_첫_그림_접힘(out): + assert out["profileFirst"] == { + "페이지 설정": True, + "계획노선": True, + "선택 포인트 상세 설정": False, + } + assert out["crossFirst"]["B06_View_Title"] is True + assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 + + +def test_B05_B06_편_뒤_다시_그림_유지(out): + assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로 + assert out["afterRedraw"]["계획노선"] is False + assert out["afterRedraw"]["B06_View_Title"] is False + assert out["sameRouteNode"] is True + + +def test_B07_첫_그림_접힘(out): + assert out["b07EntryFolded"] == [] # 들어올 때 = 라우터가 비운 그릇 → 기본값 + assert out["b07First"] == { + "plan": True, + "longitudinal": True, + "cross": True, + "mass_haul": False, + } + + +def test_B07_다시_세워도_접힘_이어_받음(out): + assert out["b07Redraw"] == { + "plan": True, + "longitudinal": True, + "cross": False, + "mass_haul": True, + } + + +def test_B07_다시_세우기는_옛_목록을_기다리기_전에_읽음(): + page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts") + body = page[page.index("export async function renderB07DesignDetail(") :] + assert body.index("const folded = foldedGroups(root);") < body.index("await ") + assert "crossLayoutSlot,\n folded,\n );" in body.replace("\r\n", "\n") + + +def test_B07_첫_장은_표지_기억_있으면_그_장(): + page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts") + assert 'const cover = drawings.findIndex((item) => item.kind === "cover");' in page + assert "const first = reopen >= 0 ? reopen : Math.max(cover, 0);" in page + + +def test_B09_기본_접힘_좌측은_한_번만_세움(): + shell = _read("B09_Estimation/B09_Estimation_UI_Shell.ts") + assert 'createPanelCard("데이터 점검", true)' in shell + assert 'createPanelCard("공기 옵션", true)' in _read( + "B09_Estimation/draft/B09_Draft_Schedule_Panel.ts" + ) + # 다시 그림(show) 은 가운데 판만 바꿈 — 좌측 카드는 쪽 그릴 때 한 번 + show = shell[shell.index("const show = (tab: TabId") : shell.index("const run = async (")] + assert "createPanelCard" not in show and "left." not in show + print_page = _read("B09_Estimation/draft/B09_Draft_Print_Page.ts") + assert 'createPanelCard("영역")' in print_page and 'createPanelCard("쪽 목록")' in print_page