diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index a85e64072..003fd85d3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -137,6 +137,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④). const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { + void fillCrossLayout(drawing); if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; @@ -144,28 +145,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. const scope = isCrossSheet(drawing) ? "sheet" : "station"; - const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope); - infoPanelHost.replaceChildren(panel); - if (scope === "sheet") void appendCrossLayout(drawing, panel); + infoPanelHost.replaceChildren( + buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + ); }; - /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */ - const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => { + /** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */ + const crossLayoutSlot = document.createElement("div"); + crossLayoutSlot.className = "b07-cross-layout-slot"; + let crossLayoutRequest: Promise | undefined; + + /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */ + const fillCrossLayout = async (drawing: DesignDrawingItem) => { if (!projectId) return; - const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1; let layout: CrossLayout; try { - layout = await fetchCrossLayout(projectId); + layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId)); } catch { + crossLayoutRequest = undefined; return; // 배치 칸만 빠짐 — 도면 보기는 그대로 } - if (currentDrawing?.id !== drawing.id || !panel.isConnected) return; + if (currentDrawing?.id !== drawing.id) return; + const number = isCrossSheet(drawing) + ? drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1 + : 0; const save = async (next: CrossLayout) => { if (!mayDiscardEdits()) return; showLoadingOverlay(); try { await saveCrossLayout(projectId, next); - reopenCrossSheet = number; + reopenCrossSheet = number || undefined; + reopenDrawingId = number ? undefined : currentDrawing?.id; cad.destroy(); void renderB07DesignDetail(root); } catch (error) { @@ -174,10 +184,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hideLoadingOverlay(); } }; - panel.append( + crossLayoutSlot.replaceChildren( buildCrossLayoutControls( - layout.sheets[String(number)] ?? layout.default, - (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }), + (number && layout.sheets[String(number)]) || layout.default, + number + ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) + : null, (grid) => void save({ default: grid, sheets: {} }), ), ); @@ -517,6 +529,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }, } : undefined, + crossLayoutSlot, ); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 9fcda741d..83bfd7725 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -7,7 +7,7 @@ * ========================================================================== */ import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { showToast } from "@ui/ui_template_elements"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; @@ -46,6 +46,8 @@ export function buildDrawingSidePanel( errorMessage?: string, devBypass = false, plan?: PlanManageControls, + /** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */ + crossTools?: HTMLElement, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; @@ -147,6 +149,7 @@ export function buildDrawingSidePanel( // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. section.append(drawingButton(drawing, drawing.label)); } + if (cross && crossTools) section.append(crossTools); panel.append(section); } attachCollapsible(panel); @@ -172,54 +175,52 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } -/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */ +/** + * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」. + * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. + */ export function buildCrossLayoutControls( grid: [number, number], - onApply: (grid: [number, number]) => void, + onApply: ((grid: [number, number]) => void) | null, onAllDefault: (grid: [number, number]) => void, ): HTMLElement { const box = document.createElement("div"); - box.className = "b07-info__layout"; - const number = (value: number, label: string): HTMLInputElement => { - const input = document.createElement("input"); - input.type = "number"; - input.min = "1"; - input.max = "10"; - input.step = "1"; - input.value = String(value); - input.ariaLabel = label; - return input; - }; - const cols = number(grid[0], "열"); + box.className = "b07-cross-layout"; + const number = (value: number, label: string) => + createInputField({ + label, + type: "number", + value: String(value), + min: 1, + max: 10, + compact: true, + }); + const cols = number(grid[0], "배치 열"); const rows = number(grid[1], "행"); const read = (): [number, number] | null => { - const value: [number, number] = [Number(cols.value), Number(rows.value)]; + const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)]; return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null; }; - const action = (text: string, title: string, run: (grid: [number, number]) => void) => { - const node = document.createElement("button"); - node.type = "button"; - node.className = "b07-plan-tools__button"; - node.textContent = text; - node.title = title; - node.addEventListener("click", () => { - const value = read(); - if (value) run(value); - else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => { + const button = createButton({ + label, + variant: "ghost", + size: "sm", + disabled: !run, + onClick: () => { + const value = read(); + if (value) run?.(value); + else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + }, }); - return node; + button.title = title; + return button; }; - const label = document.createElement("span"); - label.textContent = "배치 열"; - const times = document.createElement("span"); - times.textContent = "× 행"; box.append( - label, - cols, - times, - rows, - action("적용", "이 장만 이 배치로", onApply), - action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + cols.root, + rows.root, + action("이 장", "이 장만 이 배치로", onApply), + action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), ); return box; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 3835e57aa..e99f64b41 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,18 +225,41 @@ color: var(--color-text-muted); } -/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */ -.b07-info__layout { +/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장]. + 좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */ +.b07-cross-layout-slot { + grid-column: 1 / -1; +} + +.b07-cross-layout-slot:empty { + display: none; +} + +.b07-cross-layout { display: flex; flex-wrap: wrap; align-items: center; - gap: var(--spacing-4); - color: var(--color-text-muted); - font-size: var(--text-caption); + gap: var(--spacing-4) var(--spacing-8); } -.b07-info__layout input { - width: 44px; +.b07-cross-layout > .ui-field { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-4); +} + +.b07-cross-layout .ui-field__label { + margin: 0; + white-space: nowrap; +} + +.b07-cross-layout .ui-input { + width: 48px; +} + +.b07-cross-layout .ui-field__error { + display: none; } /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs new file mode 100644 index 000000000..dc221cc88 --- /dev/null +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -0,0 +1,165 @@ +/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를 + * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · + * 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜. + */ +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 UI = path.join(ROOT, "ui_template"); +const cache = new Map(); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + this.value = ""; + this.disabled = false; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(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(""); + } + setAttribute(key, value) { + this.attrs[key] = String(value); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + replaceChildren(...kids) { + this.children = [...kids]; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({})); + } +} +const all = (node) => [node, ...node.children.flatMap(all)]; +const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name)); + +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; + +const toasts = []; +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 (request === "@ui/ui_template_elements") + return { + ...loadTs(path.join(UI, "ui_template_elements.ts")), + showToast: (message, kind) => toasts.push([kind, message]), + }; + if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} }; + if (request === "@ui/ui_template_locale") + return { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), + }; + if (request.startsWith(".")) { + const target = path.join(path.dirname(full), `${request}.ts`); + return fs.existsSync(target) ? loadTs(target) : {}; + } + return {}; // 이 시험이 안 부르는 바깥 길(API · 설정 · CAD) + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs( + path.join(B07, "B07_DesignDetail_UI_Panels.ts"), +); + +const out = {}; +const slot = new FakeNode("div"); +slot.className = "b07-cross-layout-slot"; +const panel = buildDrawingSidePanel( + [ + { id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false }, + { id: "cross_s1", kind: "cross", label: "1장", confirmed: false }, + { id: "cross_s2", kind: "cross", label: "2장", confirmed: true }, + ], + () => {}, + undefined, + false, + undefined, + slot, +); +const cross = all(panel).find((n) => n.dataset.kind === "cross"); +out.slotLast = cross.children.at(-1) === slot; +out.slotCount = all(panel).filter((n) => n === slot).length; + +// 장 — [이 장] 켜짐 · 값 바꿔 누름 +const applied = []; +const defaults = []; +slot.replaceChildren( + buildCrossLayoutControls( + [3, 4], + (grid) => applied.push(grid), + (grid) => defaults.push(grid), + ), +); +const box = slot.children[0]; +const buttons = () => all(slot).filter((n) => n.tagName === "BUTTON"); +const inputs = () => all(slot).filter((n) => n.tagName === "INPUT"); +out.sheet = { + boxClass: box.className, + labels: byClass(slot, "ui-field__label").map((n) => n.textContent), + inputs: inputs().map((n) => ({ + value: n.value, + type: n.attrs.type, + min: n.attrs.min, + max: n.attrs.max, + cls: n.className, + })), + buttons: buttons().map((n) => ({ text: n.textContent, cls: n.className, disabled: n.disabled })), +}; +inputs()[0].value = "2"; +inputs()[1].value = "5"; +buttons()[0].click(); +buttons()[1].click(); +inputs()[0].value = "11"; +buttons()[0].click(); +out.sheetClicks = { applied, defaults, toasts: [...toasts] }; + +// 장 아님 — [이 장] 꺼짐 · [모든 장]은 됨 +const defaults2 = []; +slot.replaceChildren(buildCrossLayoutControls([4, 4], null, (grid) => defaults2.push(grid))); +buttons()[0].click(); +buttons()[1].click(); +out.notSheet = { + disabled: buttons().map((n) => n.disabled), + defaults: defaults2, + values: inputs().map((n) => n.value), +}; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py new file mode 100644 index 000000000..58275b487 --- /dev/null +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""153-1 B07 횡단 장 배치 칸. + +못박는 것 + - 배치 칸 자리 = 좌측 「횡단도」 묶음 맨 아래(한 번만) — 정보 칸을 다시 그려도 안 사라짐. + - 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장]. + - 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만. + +Node 헬퍼 `helper_153_b07_cross_layout.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_153_b07_cross_layout.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): + assert out["slotLast"] is True + assert out["slotCount"] == 1 + + +def test_공용_입력칸과_작은_단추(out): + sheet = out["sheet"] + assert sheet["boxClass"] == "b07-cross-layout" + assert sheet["labels"] == ["배치 열", "행"] + assert [i["value"] for i in sheet["inputs"]] == ["3", "4"] + for field in sheet["inputs"]: + assert field["type"] == "number" and field["min"] == "1" and field["max"] == "10" + assert "ui-input--sm" in field["cls"] + assert [b["text"] for b in sheet["buttons"]] == ["이 장", "모든 장"] + for button in sheet["buttons"]: + assert button["cls"] == "ui-btn ui-btn--ghost ui-btn--sm" and not button["disabled"] + + +def test_이_장_모든_장_누름(out): + clicks = out["sheetClicks"] + assert clicks["applied"] == [[2, 5]] + assert clicks["defaults"] == [[2, 5]] + assert [t[0] for t in clicks["toasts"]] == ["error"] # 11 = 범위 밖 + + +def test_장_아니면_이_장_꺼짐(out): + not_sheet = out["notSheet"] + assert not_sheet["disabled"] == [True, False] + assert not_sheet["defaults"] == [[4, 4]] + assert not_sheet["values"] == ["4", "4"]