From 94491b674aed23f9c1c5c2da75e28f5ad5c66684 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 20:07:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B9=B8=20[=ED=8F=89=EB=A9=B4=EB=8F=84=20=EA=B4=80=EB=A6=AC]?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=E2=86=92=20=EB=AA=A8=EB=8B=AC(?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=ED=94=BC=EC=86=8C=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=9E=A5=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=ED=92=80=EA=B8=B0)=20=C2=B7=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=EC=97=94=20=EC=9E=A5=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=EB=A7=8C(130-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 13 +- .../B07_DesignDetail_UI_Panels.ts | 9 +- .../B07_DesignDetail_UI_PlanKinds.ts | 100 +++-- .../B07_DesignDetail_UI_PlanManage.ts | 210 ++++++++++ .../B07_DesignDetail_UI_Style.css | 53 ++- .../tester/helper_130_b07_plan_manage.cjs | 373 ++++++++++++++++++ resources/tester/test_130_b07_plan_manage.py | 103 +++++ 7 files changed, 793 insertions(+), 68 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts create mode 100644 resources/tester/helper_130_b07_plan_manage.cjs create mode 100644 resources/tester/test_130_b07_plan_manage.py diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index f53756663..fdc495cc9 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -79,7 +79,7 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 화면 조립 * -------------------------------------------------------------------------- */ -/** 종류 · 대피소 자리를 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */ +/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */ let reopenDrawingId: string | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { @@ -463,10 +463,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { projectId ? { projectId, - // 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. - onChanged: () => { + // [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. + onClosed: (changed, openId) => { + if (!changed) { + const target = drawings.find((item) => item.id === openId); + if (target) selectDrawing(target); + return; + } if (!mayDiscardEdits()) return; - reopenDrawingId = currentDrawing?.id; + reopenDrawingId = openId ?? currentDrawing?.id; cad.destroy(); void renderB07DesignDetail(root); }, diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index fc2dc82dd..8e2aa3eb7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -9,7 +9,8 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; -import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds"; +import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; +import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -43,7 +44,7 @@ export function buildDrawingSidePanel( onSelect: (drawing: DesignDrawingItem) => void, errorMessage?: string, devBypass = false, - plan?: PlanKindsControls, + plan?: PlanManageControls, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; @@ -90,7 +91,9 @@ export function buildDrawingSidePanel( if (group.kind === "plan") { // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. const plans = drawings.filter((item) => item.kind === "plan"); - panel.append(buildPlanSection(plans, drawingButton, plan)); + panel.append( + buildPlanSection(plans, drawingButton, plan ? () => openPlanManager(plan) : undefined), + ); continue; } const items = group.kind diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index b36029d59..55634f479 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,7 +1,8 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · - * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). + * 사용자 추가)별 도면 단추 + [평면도 관리] 단추 (PLAN 60 · 67 · 130장). + * 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀. * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. @@ -12,8 +13,6 @@ import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, - deletePlanKind, - fetchPlanKinds, savePlanMarks, updatePlanKind, type DesignDrawingItem, @@ -43,7 +42,10 @@ function el( } /** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */ -async function run(action: () => Promise, controls: PlanKindsControls): Promise { +export async function run( + action: () => Promise, + controls: PlanKindsControls, +): Promise { try { await action(); controls.onChanged(); @@ -59,7 +61,7 @@ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ ]; /** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */ -function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); const pick = createSelectField({ @@ -137,7 +139,7 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme return form; } -function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "대피소 · 사토장 자리")); form.append( @@ -192,68 +194,54 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ -export function buildPlanSection( +/** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */ +export function groupPlanSheets( items: DesignDrawingItem[], - makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, - controls?: PlanKindsControls, -): HTMLElement { - const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); - section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); - +): { kindId: string; name: string; sheets: { drawing: DesignDrawingItem; label: string }[] }[] { const kinds = new Map(); for (const item of items) { const key = item.plan_kind ?? item.id; kinds.set(key, [...(kinds.get(key) ?? []), item]); } - for (const [kindId, sheets] of kinds) { + return [...kinds].map(([kindId, sheets]) => { const name = sheets[0].plan_kind_name ?? sheets[0].label; + return { + kindId, + name, + sheets: sheets.map((drawing, index) => ({ + drawing, + label: sheets.length > 1 ? `${name} ${index + 1}장` : name, + })), + }; + }); +} + +/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추, 아래에 [평면도 관리](130-1). */ +export function buildPlanSection( + items: DesignDrawingItem[], + makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, + onManage?: () => void, +): HTMLElement { + const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); + section.dataset.kind = "plan"; + section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); + + for (const { kindId, name, sheets } of groupPlanSheets(items)) { const block = el("div", "b07-plan-kind"); block.dataset.planKind = kindId; - // 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추. - const head = el("div", "b07-plan-kind__head"); - const buttons = sheets.map((drawing, index) => - makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), - ); - head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); - if (controls) { - const remove = el("button", "b07-plan-tools__remove", "×"); - remove.type = "button"; - remove.title = `「${name}」 종류 지우기`; - remove.addEventListener("click", () => { - if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) - return; - void run(() => deletePlanKind(controls.projectId, kindId), controls); - }); - head.append(remove); - } - block.append(head, ...(sheets.length > 1 ? buttons : [])); + // 한 장이면 [도면 단추] 한 줄 · 여러 장이면 [이름] 아래 장 단추. + const buttons = sheets.map(({ drawing, label }) => makeButton(drawing, label)); + if (sheets.length > 1) block.append(el("span", "b07-plan-kind__name", name)); + block.append(...buttons); section.append(block); } - if (controls) { - // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. - const tools = el("details", "b07-plan-tools"); - tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); - const load = (): void => { - if (!tools.open || tools.dataset.loaded) return; - tools.dataset.loaded = "true"; - void fetchPlanKinds(controls.projectId) - .then((state) => tools.append(kindForm(state, controls), marksForm(state, controls))) - .catch((error: unknown) => { - delete tools.dataset.loaded; - showToast( - error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.", - "error", - ); - }); - }; - tools.addEventListener("toggle", load); - // 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6). - tools.open = true; - load(); - section.append(tools); + if (onManage) { + // 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1). + const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리"); + manage.type = "button"; + manage.addEventListener("click", onManage); + section.append(manage); } return section; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts new file mode 100644 index 000000000..5328ed77f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -0,0 +1,210 @@ +/* ============================================================================= + * B07_DesignDetail_UI_PlanManage.ts + * [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음. + * + * 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록 + * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기] + * 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고, + * 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createCadHost } from "@ui/cad_host/cad_host"; +import { + deletePlanKind, + fetchDesignDrawing, + fetchDesignDrawingList, + fetchPlanKinds, + invalidateDesignDrawing, + type CadDrawing, + type DesignDrawingItem, + type PlanKindsState, +} from "./B07_DesignDetail_Api_Fetch"; +import { + groupPlanSheets, + kindForm, + marksForm, + run, + type PlanKindsControls, +} from "./B07_DesignDetail_UI_PlanKinds"; + +export interface PlanManageControls { + projectId: string; + /** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */ + onClosed: (changed: boolean, openId?: string) => void; +} + +function el( + tag: K, + className: string, + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + node.className = className; + if (text) node.textContent = text; + return node; +} + +function button(className: string, text: string, onClick: () => void): HTMLButtonElement { + const node = el("button", className, text); + node.type = "button"; + node.addEventListener("click", onClick); + return node; +} + +export function openPlanManager(controls: PlanManageControls): void { + const { projectId } = controls; + let changed = false; + let closed = false; + /** 미리보기 중인 장 — 늦게 온 응답이 다른 장을 덮지 않게 이것과 맞춰 봄. */ + let picked: string | undefined; + const preview = createCadHost({ title: "평면도 미리보기" }); + const side = el("div", "b07-plan-manage__side"); + const head = el("div", "b07-plan-manage__head"); + const view = el("div", "b07-plan-manage__view"); + view.append(head, preview.element); + + // 종류 · 자리 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때). + const edits: PlanKindsControls = { + projectId, + onChanged: () => { + changed = true; + void refresh(); + }, + }; + + const kindList = (state: PlanKindsState, sheets: DesignDrawingItem[]): HTMLElement => { + const list = el("div", "b07-plan-tools__block b07-plan-manage__kinds"); + list.append(el("strong", "", "종류 목록")); + for (const kind of state.kinds) { + const row = el("div", "b07-plan-kind__head"); + row.dataset.planKind = kind.id; + const count = sheets.filter((item) => item.plan_kind === kind.id).length; + row.append(el("span", "b07-plan-kind__name", `${kind.name} · ${count}장`)); + const remove = button("b07-plan-tools__remove", "×", () => { + if ( + !window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`) + ) + return; + void run(() => deletePlanKind(projectId, kind.id), edits); + }); + remove.title = `「${kind.name}」 종류 지우기`; + row.append(remove); + list.append(row); + } + return list; + }; + + const sheetList = (sheets: DesignDrawingItem[]): HTMLElement => { + const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets"); + list.append(el("strong", "", "평면도 장")); + for (const group of groupPlanSheets(sheets)) { + for (const { drawing, label } of group.sheets) { + const item = button("b07-drawing-button", label, () => void show(drawing, label)); + item.dataset.drawingId = drawing.id; + item.dataset.confirmed = String(drawing.confirmed); + list.append(item); + } + } + return list; + }; + + const markActive = (): void => + side + .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked))); + + /** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */ + const show = async (drawing: DesignDrawingItem, label: string): Promise => { + picked = drawing.id; + markActive(); + head.replaceChildren(el("strong", "", label)); + preview.beginLoading(); + try { + const response = await fetchDesignDrawing(projectId, drawing.id); + if (closed || picked !== drawing.id) return; + const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전"); + badge.dataset.confirmed = String(response.confirmed); + head.append(badge); + if (response.confirmed) + head.append( + button("b07-plan-tools__button", "확정 풀기", () => { + void run(() => invalidateDesignDrawing(projectId, drawing.id), edits); + }), + ); + head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id))); + preview.load( + response.drawing, + null, + false, + {}, + { + recoveryScope: "b07-plan-preview", + readOnly: true, + viewOnly: true, + hostTitle: "평면도 미리보기", + ribbonCollapsed: true, + panelsCollapsed: true, + }, + ); + } catch (error) { + if (closed || picked !== drawing.id) return; + preview.fail(error instanceof Error ? error.message : "평면도를 불러오지 못했습니다."); + } + }; + + /** 종류 · 장 목록을 서버에서 다시 받아 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄. */ + const refresh = async (): Promise => { + try { + const [state, list] = await Promise.all([ + fetchPlanKinds(projectId), + fetchDesignDrawingList(projectId), + ]); + if (closed) return; + const sheets = list.drawings.filter((item) => item.kind === "plan"); + side.replaceChildren( + kindList(state, sheets), + kindForm(state, edits), + marksForm(state, edits), + sheetList(sheets), + ); + const all = groupPlanSheets(sheets).flatMap((group) => group.sheets); + const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0]; + if (keep) void show(keep.drawing, keep.label); + else { + picked = undefined; + head.replaceChildren(el("span", "b07-plan-kind__name", "평면도 장이 없습니다.")); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "평면도 목록을 받지 못했습니다.", "error"); + } + }; + + let closeModal = (): void => undefined; + /** 한 번만 — 미리보기 듣기를 멈추고 페이지에 알림. */ + const finish = (openId?: string): void => { + if (closed) return; + closed = true; + preview.destroy(); + closeModal(); + controls.onClosed(changed, openId); + }; + + openModal({ + title: "평면도 관리", + closeLabel: "닫기", + dialogClass: "b07-plan-manage", + beforeClose: () => { + finish(); + return true; + }, + mount: (body, close) => { + closeModal = close; + const grid = el("div", "b07-plan-manage__grid"); + grid.append(side, view); + body.append(grid); + void refresh(); + }, + }); +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index a9257b90a..b0c45377c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -236,7 +236,7 @@ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ -/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */ +/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 [평면도 관리](PLAN 60 · 67 · 130장) */ .b07-plan-kind { display: flex; flex-direction: column; @@ -257,14 +257,57 @@ font-size: var(--text-caption); } -.b07-plan-tools { +.b07-plan-manage-open { margin-top: var(--spacing-4); - font-size: var(--text-body-sm); } -.b07-plan-tools summary { +/* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */ +.ui-modal.b07-plan-manage { + width: min(1320px, 96vw); + --modal-max: 94vh; +} + +.b07-plan-manage__grid { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: var(--spacing-16); + height: calc(94vh - 96px); + min-height: 0; +} + +.b07-plan-manage__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; + overflow-y: auto; +} + +.b07-plan-manage__view { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.b07-plan-manage__view > .cad-host { + flex: 1 1 0; + height: auto; +} + +.b07-plan-manage__head { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b07-plan-manage__badge { color: var(--color-text-muted); - cursor: pointer; + font-size: var(--text-caption); +} + +.b07-plan-manage__badge[data-confirmed="true"] { + color: var(--color-success); } .b07-plan-tools__block { diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs new file mode 100644 index 000000000..5b6aea7ba --- /dev/null +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -0,0 +1,373 @@ +/* 130-1 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기)을 + * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · + * 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B07 = path.join(ROOT, "B07_DesignDetail"); +const cache = new Map(); + +// ── 화면 그릇 흉내 ───────────────────────────────────────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + this.value = ""; + this.hidden = false; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + contains: (name) => self._classes.has(name), + }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + replaceChildren(...kids) { + this.children = [...kids]; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + (this.listeners.click || []).forEach((handler) => handler({})); + } + /** `.a` · `.a .b`(자손) 만. */ + querySelectorAll(selector) { + const parts = selector + .trim() + .split(/\s+/) + .map((p) => p.slice(1)); + const found = []; + const walk = (node, depth) => + node.children.forEach((kid) => { + let next = depth; + if (kid.classList && kid.classList.contains(parts[depth])) { + if (depth === parts.length - 1) found.push(kid); + else next = depth + 1; + } + walk(kid, next); + }); + walk(this, 0); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} +const all = (node) => [node, ...node.children.flatMap(all)]; +const byText = (node, tag, text) => + all(node).filter((n) => n.tagName === tag && n.textContent === text); + +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; +const confirms = []; +globalThis.window = { confirm: (message) => (confirms.push(message), true) }; + +// ── 공용 부품 가짜 ───────────────────────────────────────────────────────────── +const toasts = []; +const modals = []; +const cads = []; +const selects = []; +const STUBS = { + "@ui/ui_template_elements": { + showToast: (message, kind) => toasts.push([kind, message]), + createSelectField: (opts) => { + const root = new FakeNode("select"); + const select = { value: opts.options[0]?.value ?? "", disabled: false }; + const field = { + root, + select, + label: opts.ariaLabel, + change: (value) => ((select.value = value), opts.onChange && opts.onChange()), + setOptions: (options, value) => (select.value = value ?? options[0]?.value ?? ""), + }; + selects.push(field); + return field; + }, + }, + "@ui/ui_template_checkbox": { + createCheckbox: (label, opts = {}) => { + const root = new FakeNode("label"); + root.textContent = label; + return { root, input: { value: opts.value ?? "", checked: !!opts.checked } }; + }, + }, + "@ui/ui_template_modal": { + openModal: (opts) => { + const body = new FakeNode("div"); + const modal = { opts, body, open: true }; + modals.push(modal); + opts.mount(body, () => (modal.open = false)); + return { close: () => (modal.open = false) }; + }, + }, + "@ui/cad_host/cad_host": { + createCadHost: (opts) => { + const cad = { opts, element: new FakeNode("div"), loads: [], destroyed: false }; + cad.beginLoading = () => {}; + cad.fail = (detail) => toasts.push(["error", detail]); + cad.load = (drawing, meta, frameEdit, fields, extra) => + cad.loads.push({ drawing, meta, extra }); + cad.destroy = () => (cad.destroyed = true); + cads.push(cad); + return cad; + }, + }, + "@ui/ui_template_collapsible": { attachCollapsible: () => {} }, + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), + }, + "../B05_Profile/B05_Profile_UI_Structures_Fields": { + stationFields: () => ({ wrap: new FakeNode("div"), write: () => {}, read: () => 0 }), + }, +}; + +// ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ─────────────────────────────── +const calls = []; +const state = { + pieces: [ + { id: "terrain", name: "지형" }, + { id: "route", name: "노선" }, + ], + scales: [1000, 1200], + default_scale: 1200, + watershed_scale: 6000, + kinds: [ + { id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 }, + { id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 }, + ], + marks: [], + station_interval_m: 20, +}; +let confirmed = new Set(["plan_k_route"]); +/** 종류 하나 = 장 하나 · 「지형」만 두 장. */ +const sheets = () => + state.kinds.flatMap((kind) => + (kind.id === "k_terrain" ? [1, 2] : [1]).map((n) => { + const id = n === 1 ? `plan_${kind.id}` : `plan_${kind.id}_${n}`; + return { + id, + kind: "plan", + label: kind.name, + chainage_m: null, + confirmed: confirmed.has(id), + plan_kind: kind.id, + plan_kind_name: kind.name, + }; + }), + ); +const copy = () => JSON.parse(JSON.stringify(state)); +const API = { + fetchPlanKinds: async () => copy(), + fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }), + fetchDesignDrawing: async (_, id) => ( + calls.push(["fetchDrawing", id]), + { id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } } + ), + addPlanKind: async (_, name, pieces, layout, scale) => { + calls.push(["add", name, pieces, layout, scale]); + state.kinds.push({ id: "k_new", name, pieces, layout, scale }); + return copy(); + }, + updatePlanKind: async (_, id, name, pieces, scale) => { + calls.push(["update", id, name, pieces, scale]); + Object.assign( + state.kinds.find((k) => k.id === id), + { name, pieces, scale }, + ); + return copy(); + }, + deletePlanKind: async (_, id) => { + calls.push(["delete", id]); + state.kinds = state.kinds.filter((k) => k.id !== id); + return copy(); + }, + savePlanMarks: async () => copy(), + invalidateDesignDrawing: async (_, id) => { + calls.push(["invalidate", id]); + confirmed.delete(id); + }, +}; + +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 === "./B07_DesignDetail_Api_Fetch") return API; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const settle = () => new Promise((done) => setTimeout(done, 0)); +const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts")); + +(async () => { + const out = {}; + const closes = []; + const selected = []; + const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) }; + const panel = buildDrawingSidePanel( + [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()], + (drawing) => selected.push(drawing.id), + undefined, + false, + plan, + ); + const section = panel.querySelector(".b07-plan"); + out.left = { + manage: byText(section, "BUTTON", "평면도 관리").length, + sheetButtons: section.querySelectorAll(".b07-drawing-button").map((b) => b.dataset.drawingId), + formFields: all(section).filter((n) => ["SELECT", "INPUT", "LABEL"].includes(n.tagName)).length, + remove: byText(section, "BUTTON", "×").length, + texts: section.textContent, + }; + + // 단추 → 모달 + byText(section, "BUTTON", "평면도 관리")[0].click(); + await settle(); + const modal = modals[0]; + const cad = cads[0]; + const kindRows = () => + modal.body + .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") + .map((row) => row.dataset.planKind); + const sheetIds = () => + modal.body + .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .map((b) => b.dataset.drawingId); + const kindPick = () => selects.filter((s) => s.label === "종류").at(-1); + const action = () => + all(modal.body).find( + (n) => n.tagName === "BUTTON" && ["추가", "업데이트"].includes(n.textContent), + ); + out.open = { + title: modal.opts.title, + dialogClass: modal.opts.dialogClass, + kinds: kindRows(), + sheets: sheetIds(), + hasKindForm: !!kindPick(), + hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"), + preview: cad.loads.at(-1).drawing.entities[0].id, + extra: cad.loads.at(-1).extra, + }; + + // 종류 추가 — 「새 종류」 · 이름 · 조각 하나 + kindPick().change(""); + all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value = + "sub6임시"; + action().click(); + await settle(); + await settle(); + out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") }; + + // 업데이트 — 새 종류를 골라 이름 · 축척 바꿈 + kindPick().change("k_new"); + out.updateLabel = action().textContent; + all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value = + "sub6임시2"; + selects.filter((s) => s.label === "축척").at(-1).select.value = "1000"; + action().click(); + await settle(); + await settle(); + out.update = { + call: calls.find((c) => c[0] === "update"), + rowText: modal.body + .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") + .find((row) => row.dataset.planKind === "k_new").textContent, + }; + + // 지우기 — 그 줄의 × + const row = modal.body + .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") + .find((r) => r.dataset.planKind === "k_new"); + byText(row, "BUTTON", "×")[0].click(); + await settle(); + await settle(); + out.remove = { + kinds: kindRows(), + call: calls.find((c) => c[0] === "delete"), + confirms: confirms.length, + }; + + // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기]) + const pickSheet = async (id) => { + modal.body + .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .find((b) => b.dataset.drawingId === id) + .click(); + await settle(); + }; + await pickSheet("plan_k_terrain_2"); + const head = () => modal.body.querySelector(".b07-plan-manage__head"); + out.sheet2 = { + preview: cad.loads.at(-1).drawing.entities[0].id, + head: head().textContent, + active: modal.body + .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .filter((b) => b.dataset.active === "true") + .map((b) => b.dataset.drawingId), + }; + await pickSheet("plan_k_route"); + out.routeHead = head().textContent; + byText(head(), "BUTTON", "확정 풀기")[0].click(); + await settle(); + await settle(); + out.release = { + call: calls.find((c) => c[0] === "invalidate"), + head: head().textContent, + preview: cad.loads.at(-1).drawing.entities[0].id, + }; + + // 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤 + modal.opts.beforeClose(); + out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open }; + + // 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id + byText(section, "BUTTON", "평면도 관리")[0].click(); + await settle(); + await settle(); + const modal2 = modals[1]; + modal2.body + .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .find((b) => b.dataset.drawingId === "plan_k_route") + .click(); + await settle(); + byText(modal2.body.querySelector(".b07-plan-manage__head"), "BUTTON", "CAD 에서 열기")[0].click(); + out.openInCad = { closes: closes.slice(1), destroyed: cads[1].destroyed, open: modal2.open }; + out.toasts = toasts; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py new file mode 100644 index 000000000..457190f4f --- /dev/null +++ b/resources/tester/test_130_b07_plan_manage.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""130-1 B07 [평면도 관리] 모달. + +못박는 것 + - 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음. + - 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용). + - 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음. + - 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. + +Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_130_b07_plan_manage.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_좌측은_장_목록과_관리_단추만(out): + left = out["left"] + assert left["manage"] == 1 + assert left["sheetButtons"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"] + assert left["formFields"] == 0 # 드롭다운 · 이름 칸 · 체크박스 없음 + assert left["remove"] == 0 + assert "대피소" not in left["texts"] and "종류 추가" not in left["texts"] + + +def test_단추를_누르면_모달(out): + opened = out["open"] + assert opened["title"] == "평면도 관리" + assert opened["dialogClass"] == "b07-plan-manage" + assert opened["kinds"] == ["k_terrain", "k_route"] + assert opened["sheets"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"] + assert opened["hasKindForm"] and opened["hasMarks"] + + +def test_첫_장_미리보기는_보기_전용(out): + assert out["open"]["preview"] == "plan_k_terrain" + extra = out["open"]["extra"] + assert extra["readOnly"] is True and extra["viewOnly"] is True + assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침 + + +def test_종류_추가(out): + assert out["add"]["call"] == ["add", "sub6임시", [], "plan", 1200] + assert out["add"]["kinds"][-1] == "k_new" + assert out["add"]["sheets"][-1] == "plan_k_new" # 장 목록도 다시 받음 + + +def test_종류_업데이트(out): + assert out["updateLabel"] == "업데이트" + assert out["update"]["call"] == ["update", "k_new", "sub6임시2", [], 1000] + assert out["update"]["rowText"].startswith("sub6임시2 · 1장") + + +def test_종류_지우기(out): + assert out["remove"]["call"] == ["delete", "k_new"] + assert out["remove"]["confirms"] == 1 + assert out["remove"]["kinds"] == ["k_terrain", "k_route"] + + +def test_미리보기_장_바꾸기(out): + s2 = out["sheet2"] + assert s2["preview"] == "plan_k_terrain_2" + assert s2["active"] == ["plan_k_terrain_2"] + assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"] + + +def test_확정이면_확정_풀기(out): + assert "확정 풀기" in out["routeHead"] + rel = out["release"] + assert rel["call"] == ["invalidate", "plan_k_route"] + assert "확정 풀기" not in rel["head"] + assert rel["preview"] == "plan_k_route" # 보던 장 그대로 + + +def test_닫으면_좌측_다시_읽기_신호(out): + assert out["close"] == {"closes": [[True, None]], "destroyed": True, "open": False} + + +def test_CAD_에서_열기는_그_장(out): + assert out["openInCad"] == { + "closes": [[False, "plan_k_route"]], + "destroyed": True, + "open": False, + } + assert out["toasts"] == []