diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index dd822905f..6a0f73be3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -243,8 +243,10 @@ export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise /** 도면 축척 종류(485) — 표준 횡단 · 평면도 · 구조물은 뺌. */ export type DrawingScaleKind = "longitudinal" | "cross" | "mass_haul" | "development"; -/** 축척 분모 — null = 자동(토적 가로 · 전개도 · 종단 평면 위치도) · 범위 = [최소, 최대]. */ -export type DrawingScaleAxes = { x: number | null; y: number | null; plan?: number | null }; +/** 축척 분모 — 늘 지정값(자동 없음 · 523-1) · 범위 = [최소, 최대]. */ +export type DrawingScaleAxes = { x: number; y: number; plan?: number }; +/** 보낼 축척 — null = 기본. */ +export type DrawingScaleSend = { [K in keyof DrawingScaleAxes]: number | null }; export interface DrawingScales { scales: Record; defaults: Record; @@ -265,7 +267,7 @@ export function fetchDrawingScales(projectId: string): Promise { /** 축척 저장 — 보낸 종류만 바꿈 · 값이 바뀐 종류의 저장본은 서버가 풂. */ export function saveDrawingScales( projectId: string, - scales: Partial>, + scales: Partial>, ): Promise { return requestJson(`/projects/${projectId}/drawing-scales`, { method: "PUT", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index ba274b87d..b767ad248 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -32,6 +32,7 @@ from config.config_system import ( DRAWING_SCALE_CROSS, DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V, + DRAWING_SCALE_MASSHAUL_H, DRAWING_SCALE_MASSHAUL_V_M3_MM, ) from M02_MasterTemplete.M02_Template_Layers import ( @@ -93,16 +94,17 @@ def add_title_fields(extra: dict[str, str]) -> None: # 도면 축척(485) — 프로젝트마다 `{B07 폴더}/drawing_scales.json` `{종류: {x, y}}`(분모). -# 칸이 없거나 어긋나면 기본(`config_system`) · None = 자동(토적 가로 · 전개도 가로 · 세로). +# 칸이 없거나 어긋나면(옛 null 포함) 기본(`config_system`) — 기본은 모두 지정값(자동 없음 · 523-1). # 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다. DRAWING_SCALES_FILE = "drawing_scales.json" -DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = { - # plan = 종단 장 우측 상단 평면 위치도(521-2) · None = 자동(노선을 칸에 맞춤) - "longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": None}, +DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int]] = { + # plan = 종단 장 우측 상단 평면 위치도(521-2) + "longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": 5000}, "cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS}, # 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥) - "mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)}, - "development": {"x": None, "y": None}, + "mass_haul": {"x": DRAWING_SCALE_MASSHAUL_H, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)}, + # 전개도 = 옛 자동이 흔히 고르던 1/50(가로 · 세로 같음) + "development": {"x": 50, "y": 50}, } @@ -110,7 +112,7 @@ def drawing_scales_path(project_root: Path) -> Path: return _design_root(project_root) / DRAWING_SCALES_FILE -def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | None]]: +def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int]]: """종류마다 {x, y} — 파일에 양의 정수가 있으면 그것, 아니면 기본.""" # ponytail: 부를 때마다 작은 파일을 읽음(수정 시각 캐시는 같은 시각 두 번 쓰기에 낡음) stored: Any = {} @@ -121,7 +123,7 @@ def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | stored = {} if not isinstance(stored, dict): stored = {} - scales: dict[str, dict[str, int | None]] = {} + scales: dict[str, dict[str, int]] = {} for kind, defaults in DRAWING_SCALE_DEFAULTS.items(): row = stored.get(kind) if isinstance(stored.get(kind), dict) else {} scales[kind] = { @@ -133,8 +135,8 @@ def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | return scales -def drawing_scale(kind: str) -> dict[str, int | None]: - """이 요청 프로젝트의 그 종류 축척 {x, y}(분모 · None = 자동).""" +def drawing_scale(kind: str) -> dict[str, int]: + """이 요청 프로젝트의 그 종류 축척 {x, y}(분모).""" return read_drawing_scales(_project_root.get())[kind] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py index 006ecc1b2..40bc719a5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py @@ -34,7 +34,7 @@ from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) -# 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음. +# 분모 범위(정수) — 기본(`DRAWING_SCALE_DEFAULTS`) · 도면 관행 축척(토적 500~6,000 · 전개도 50~5,000)을 모두 담음. SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = { "longitudinal": {"x": (100, 10000), "y": (10, 2000), "plan": (500, 50000)}, "cross": {"x": (10, 1000), "y": (10, 1000)}, @@ -50,7 +50,7 @@ _KIND_LABEL = { def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]: - """{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본(자동 칸은 자동).""" + """{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본.""" if not isinstance(scales, dict): raise ValueError("축척은 {종류: {x, y}} 꼴로 보내 주세요.") checked: dict[str, dict[str, int | None]] = {} @@ -138,7 +138,7 @@ def write_drawing_scales( class DrawingScalesRequest(BaseModel): - """바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본(자동).""" + """바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본.""" scales: dict[str, Any] = Field(default_factory=dict) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 0d74116c4..e4c148abc 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -7,7 +7,6 @@ * ========================================================================== */ import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; -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 { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development"; @@ -236,99 +235,6 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } -/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */ -const CROSS_SCALE_MIN = 0.5; -const CROSS_SCALE_MAX = 2; - -/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */ -export interface CrossLayoutExtras { - scale?: { value: number; onApply: (scale: number) => void }; -} - -/** - * 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3): - * 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]). - * 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. - */ -export function buildCrossLayoutControls( - grid: [number, number], - onApply: ((grid: [number, number]) => void) | null, - onAllDefault: (grid: [number, number]) => void, - extras: CrossLayoutExtras = {}, -): HTMLElement { - const box = document.createElement("div"); - box.className = "b07-cross-layout"; - const row = (...items: HTMLElement[]): HTMLElement => { - const line = document.createElement("div"); - line.className = "b07-cross-layout__row"; - line.append(...items); - return line; - }; - 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.input.value), Number(rows.input.value)]; - return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null; - }; - 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"); - }, - }); - button.title = title; - return button; - }; - box.append( - row(cols.root, rows.root), - row( - action("이 장", "이 장만 이 배치로", onApply), - action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), - ), - ); - if (extras.scale) { - const { onApply: applyScale } = extras.scale; - const scale = createInputField({ - label: "표 배율", - type: "number", - value: String(extras.scale.value), - min: CROSS_SCALE_MIN, - max: CROSS_SCALE_MAX, - compact: true, - }); - scale.input.setAttribute("step", "0.1"); - const button = createButton({ - label: "배율 적용", - variant: "ghost", - size: "sm", - onClick: () => { - const value = Number(scale.input.value); - if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value); - else - showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error"); - }, - }); - button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; - box.append(row(scale.root, button)); - } - return box; -} - /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts index bcfa1243d..da3261908 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts @@ -3,21 +3,19 @@ * 좌측 도면 묶음 ⚙ 설정 모달(요구 280-5) — 공용 `openModal` · 머리 [원본에서 다시 그리기](281). * * 평면도 = 평면도 관리 모달 그대로(`openPlanManager`) - * 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김 + * 그 밖 = 그룹 상자(523-1) + 맨 아래 단추 줄 하나 — [안내선 숨기기](횡단) · [기본값](축척) · [취소] · [확인] + * 횡단도 = 「장 배치」(열 · 행 · 적용 이 장 / 모든 장) · 「표」(표 배율) · 축척 * 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`) + * [확인] = 바뀐 것만 저장(축척 → 배치) 뒤 닫고 다시 그림 · 바뀐 것 없으면 닫기만 · 붉은 칸 있으면 꺼짐 * [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬 * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { CrossLayout, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; -import { - buildCrossLayoutControls, - DRAWING_GROUPS, - isCrossSheet, -} from "./B07_DesignDetail_UI_Panels"; +import { DRAWING_GROUPS, isCrossSheet } from "./B07_DesignDetail_UI_Panels"; import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage"; -import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale"; +import { mountDrawingScale, settingsGroup } from "./B07_DesignDetail_UI_Settings_Scale"; type Kind = DesignDrawingItem["kind"]; @@ -40,6 +38,10 @@ export interface DrawingSettingsDeps { /** ⚙ 묶음에 드는 도면 종류 — 횡단도 카드엔 표준 횡단면도 줄이 같이 있음. */ const memberOf = (kind: Kind): Kind[] => (kind === "cross" ? ["cross", "cross_standard"] : [kind]); +/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */ +const CROSS_SCALE_MIN = 0.5; +const CROSS_SCALE_MAX = 2; + const note = (text: string): HTMLElement => { const line = document.createElement("p"); line.className = "b07-settings__note"; @@ -81,47 +83,127 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void mount: (body, close) => { closeModal = close; if (mine) body.append(note(`지금 도면: ${current?.label}`)); - if (kind === "structure") body.append(note("M02 그림 — 축척 없음")); - mountDrawingScale(body, kind, deps, close); - if (kind === "cross") mountCross(body, deps, close); + if (kind === "structure") body.append(settingsGroup("축척", note("M02 그림 — 축척 없음"))); + const confirm = createButton({ label: "확인", onClick: () => void apply() }); + const refresh = (): void => { + confirm.disabled = scale?.read() === "invalid" || cross?.read() === "invalid"; + }; + const cross = kind === "cross" ? mountCross(body, deps, refresh) : undefined; + const scale = mountDrawingScale(body, kind, deps, refresh); + + const apply = async (): Promise => { + const nextScale = scale?.read() ?? null; + const nextLayout = cross?.read() ?? null; + if (nextScale === "invalid" || nextLayout === "invalid") return; + if (nextScale && !(await scale!.save(nextScale))) return; + close(); + if (nextLayout) deps.cross.save(nextLayout, cross!.sheet); + else if (nextScale) deps.plan?.onClosed(true, deps.current()?.id); // 목록 · 도면 다시 받음 + }; + const cancel = createButton({ label: "취소", variant: "ghost", onClick: close }); + cancel.classList.add("b07-settings__cancel"); + const footer = document.createElement("div"); + footer.className = "b07-settings__actions"; + if (cross) footer.append(deps.cross.guideToggle); + if (scale) { + const reset = createButton({ label: "기본값", variant: "ghost", onClick: scale.reset }); + reset.title = "축척 칸을 기본으로 채움 — [확인]으로 씀"; + footer.append(reset); + } + footer.append(cancel, confirm); + body.append(footer); }, }); } -/** 횡단 모달 몸 — 배치 칸은 받은 뒤 · 안내선 단추는 먼저(배치를 못 받아도 남음). */ -function mountCross(body: HTMLElement, deps: DrawingSettingsDeps, close: () => void): void { +interface CrossFields { + sheet: number; + /** null = 그대로(못 받음 포함) · "invalid" = 붉은 칸 · 그 밖 = 저장할 배치 */ + read: () => CrossLayout | null | "invalid"; +} + +/** 횡단 「장 배치」 · 「표」 그룹 — 칸은 배치를 받은 뒤 채움. */ +function mountCross( + body: HTMLElement, + deps: DrawingSettingsDeps, + onCheck: () => void, +): CrossFields { const current = deps.current(); const sheet = current && isCrossSheet(current) ? deps.drawings.filter(isCrossSheet).findIndex((item) => item.id === current.id) + 1 : 0; - body.classList.add("ui-panel-form"); // 칸 · 단추 꼴 = 좌측 패널 때와 같음(269) - const holder = document.createElement("div"); - holder.className = "b07-cross-layout"; - holder.append(deps.cross.guideToggle); - body.append(holder); + const number = (label: string, min: number, max: number) => { + const field = createInputField({ label, type: "number", min, max, compact: true }); + field.input.disabled = true; // 받기 전 + field.input.addEventListener("input", onCheck); + return field; + }; + const cols = number("배치 열", 1, 10); + const rows = number("행", 1, 10); + const scope = createSelectField({ + label: "적용", + compact: true, + options: [ + ...(sheet ? [{ value: "sheet", text: "이 장만" }] : []), + { value: "all", text: "모든 장" }, + ], + value: sheet ? "sheet" : "all", + }); + scope.select.title = "이 장만 = 지금 장 · 모든 장 = 모든 장을 이 배치로(장마다 값 지움)"; + const table = number("표 배율", CROSS_SCALE_MIN, CROSS_SCALE_MAX); + table.input.setAttribute("step", "0.1"); + table.root.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; + const line = (...items: HTMLElement[]): HTMLElement => { + const row = document.createElement("div"); + row.className = "b07-cross-layout__row"; + row.append(...items); + return row; + }; + const layoutBox = settingsGroup("장 배치", line(cols.root, rows.root), line(scope.root)); + const tableBox = settingsGroup("표", line(table.root)); + layoutBox.classList.add("b07-cross-layout"); + tableBox.classList.add("b07-cross-layout"); + body.append(layoutBox, tableBox); + + let layout: CrossLayout | undefined; + let shown: [number, number] = [0, 0]; void deps.cross .layout() - .then((layout) => { - const apply = (next: CrossLayout): void => { - close(); - deps.cross.save(next, sheet); - }; - const controls = buildCrossLayoutControls( - (sheet && layout.sheets[String(sheet)]) || layout.default, - sheet - ? (grid) => apply({ ...layout, sheets: { ...layout.sheets, [String(sheet)]: grid } }) - : null, - (grid) => apply({ ...layout, default: grid, sheets: {} }), - { - scale: { - value: layout.table_scale ?? 1, - onApply: (scale) => apply({ ...layout, table_scale: scale }), - }, - }, - ); - controls.append(deps.cross.guideToggle); - holder.replaceWith(controls); + .then((got) => { + layout = got; + shown = (sheet && got.sheets[String(sheet)]) || got.default; + cols.input.value = String(shown[0]); + rows.input.value = String(shown[1]); + table.input.value = String(got.table_scale ?? 1); + for (const field of [cols, rows, table]) field.input.disabled = false; }) - .catch(() => holder.prepend(note("배치를 받지 못했습니다."))); + .catch(() => layoutBox.append(note("배치를 받지 못했습니다."))); + + return { + sheet, + read: () => { + if (!layout) return null; + const grid: [number, number] = [Number(cols.input.value), Number(rows.input.value)]; + const tableScale = Number(table.input.value); + const badGrid = !grid.every((n) => Number.isInteger(n) && n >= 1 && n <= 10); + const badTable = !(tableScale >= CROSS_SCALE_MIN && tableScale <= CROSS_SCALE_MAX); + cols.setError(badGrid ? "1~10 정수" : undefined); + rows.setError(badGrid ? "1~10 정수" : undefined); + table.setError(badTable ? `${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX}` : undefined); + if (badGrid || badTable) return "invalid"; + let next = layout; + const touched = + grid[0] !== shown[0] || + grid[1] !== shown[1] || + scope.select.value !== (sheet ? "sheet" : "all"); + if (touched && scope.select.value === "sheet") { + next = { ...next, sheets: { ...next.sheets, [String(sheet)]: grid } }; + } else if (touched) { + next = { ...next, default: grid, sheets: {} }; + } + if (tableScale !== (layout.table_scale ?? 1)) next = { ...next, table_scale: tableScale }; + return next === layout ? null : next; + }, + }; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts index 5556e1c72..1e5ef237a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts @@ -3,14 +3,13 @@ * ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만. * 종단 = 셋째 칸 「평면도」(521-2 · 우측 상단 평면 위치도) — 칸은 서버 기본값 열쇠 그대로. * - * 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」 - * [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림) - * [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장] + * 칸 = 분모 정수(늘 지정값 · 자동 없음 523-1) · 범위 밖 = 칸 붉게 + 모달 [확인] 꺼짐 + * 저장 = 모달 [확인](`_UI_Settings`)이 `save` 를 부름 — 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT + * [기본값] = 칸만 기본으로 채움 — 저장은 [확인] + * `settingsGroup` = 모달 안 그룹 상자(523-1) — 횡단 배치 · 표도 같은 상자 * ========================================================================== */ -import { createCheckbox } from "@ui/ui_template_checkbox"; import { - createButton, createInputField, hideLoadingOverlay, showConfirmDialog, @@ -21,6 +20,7 @@ import { type DrawingScaleAxes, type DrawingScaleKind, type DrawingScales, + type DrawingScaleSend, fetchDrawingScales, saveDrawingScales, } from "./B07_DesignDetail_Api_Fetch"; @@ -38,86 +38,104 @@ const AXIS_LABEL: Record = { x: "X(가로) 1/", y: "Y(세로) 1/", const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL; -/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */ +/** 모달 안 그룹 상자 — 제목(legend) + 칸. */ +export function settingsGroup(legend: string, ...children: HTMLElement[]): HTMLFieldSetElement { + const box = document.createElement("fieldset"); + box.className = "b07-settings__group"; + const title = document.createElement("legend"); + title.textContent = legend; + box.append(title, ...children); + return box; +} + +/** 축척 칸 한 벌 — 모달 [확인] · [기본값] 이 씀. */ +export interface ScaleFields { + /** null = 그대로(못 받음 포함) · "invalid" = 붉은 칸 있음 · 그 밖 = 보낼 값 */ + read: () => DrawingScaleSend | null | "invalid"; + reset: () => void; + /** 저장본 물음 → PUT — 취소 · 실패면 거짓(모달 머묾) */ + save: (next: DrawingScaleSend) => Promise; +} + +/** ⚙ 모달 몸에 축척 그룹을 붙임 — 축척 없는 종류는 undefined · `onCheck` = 칸 바뀜(모달 [확인] 켜고 끔). */ export function mountDrawingScale( body: HTMLElement, kind: string, deps: DrawingSettingsDeps, - close: () => void, -): void { - if (!isScaleKind(kind) || !deps.plan) return; - const { projectId, onClosed } = deps.plan; - const section = document.createElement("div"); - section.className = "b07-scale"; - section.textContent = "축척 받는 중…"; + onCheck: () => void, +): ScaleFields | undefined { + if (!isScaleKind(kind) || !deps.plan) return undefined; + const { projectId } = deps.plan; + const status = document.createElement("p"); + status.className = "b07-settings__note"; + status.textContent = "축척 받는 중…"; + const section = settingsGroup( + kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`, + status, + ); + section.classList.add("b07-scale"); body.append(section); + let built: Omit | undefined; + let data: DrawingScales | undefined; void fetchDrawingScales(projectId) - .then((data) => { - section.textContent = ""; - buildScaleFields(section, kind, data, async (next) => { - const saved = data.saved[kind] ?? 0; - const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`; - if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return; - showLoadingOverlay(); - try { - await saveDrawingScales(projectId, { [kind]: next }); - close(); - showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success"); - onClosed(true, deps.current()?.id); // 목록 · 도면을 서버에서 다시 받아 다시 그림 - } catch (error) { - showToast( - error instanceof Error ? error.message : "축척을 저장하지 못했습니다.", - "error", - ); - } finally { - hideLoadingOverlay(); - } - }); + .then((got) => { + data = got; + status.remove(); + built = buildScaleFields(section, kind, got, onCheck); + onCheck(); }) .catch(() => { - section.textContent = "축척을 받지 못했습니다."; + status.textContent = "축척을 받지 못했습니다."; }); + return { + read: () => built?.read() ?? null, + reset: () => built?.reset(), + save: async (next) => { + const saved = data?.saved[kind] ?? 0; + const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`; + if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return false; + showLoadingOverlay(); + try { + await saveDrawingScales(projectId, { [kind]: next }); + showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success"); + return true; + } catch (error) { + showToast(error instanceof Error ? error.message : "축척을 저장하지 못했습니다.", "error"); + return false; + } finally { + hideLoadingOverlay(); + } + }, + }; } function buildScaleFields( section: HTMLElement, kind: DrawingScaleKind, data: DrawingScales, - save: (next: DrawingScaleAxes) => Promise, -): void { + onCheck: () => void, +): Omit { const axes = Object.keys(data.defaults[kind]) as Axis[]; const fields = axes.map((axis) => { - const label = AXIS_LABEL[axis]; - const fallback = data.defaults[kind][axis] ?? null; + const fallback = data.defaults[kind][axis] as number; const range = data.ranges[kind][axis] ?? [1, 1]; const [min, max] = range; - const field = createInputField({ label, type: "number", compact: true, min, max }); + const field = createInputField({ + label: AXIS_LABEL[axis], + type: "number", + compact: true, + min, + max, + }); field.root.classList.add("b07-scale__field"); - const auto = fallback === null ? createCheckbox("자동") : undefined; - if (auto) field.root.append(auto.root); - const set = (value: number | null): void => { - field.input.value = value === null ? "" : String(value); - if (auto) auto.input.checked = value === null; - field.input.disabled = value === null && !!auto; + const set = (value: number): void => { + field.input.value = String(value); }; - set(data.scales[kind][axis] ?? null); - const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range); - return { axis, field, auto, fallback, set, read }; + set(data.scales[kind][axis] ?? fallback); + const read = () => readScaleAxis(field.input.value, range); + return { axis, field, fallback, set, read }; }); - const saveButton = createButton({ - label: "축척 저장", - onClick: () => { - const read = fields.map((f) => [f, f.read() as number | null] as const); - const now = data.scales[kind]; - if (read.every(([f, value]) => value === (now[f.axis] ?? null))) { - showToast("바뀐 축척이 없습니다.", "info"); - return; - } - const next = Object.fromEntries(read.map(([f, v]) => [f.axis, scaleToSend(v, f.fallback)])); - void save(next as DrawingScaleAxes); - }, - }); // 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림 const alertLine = document.createElement("p"); alertLine.className = "b07-scale__error"; @@ -134,35 +152,27 @@ function buildScaleFields( } alertLine.textContent = [...errors].map(([m, axes]) => `${axes.join(" · ")}: ${m}`).join(" / "); alertLine.hidden = errors.size === 0; - saveButton.disabled = errors.size > 0; + onCheck(); }; - for (const f of fields) { - f.field.input.addEventListener("input", check); - f.auto?.input.addEventListener("change", () => { - if (f.auto!.input.checked) f.set(null); - else f.field.input.disabled = false; // 빈 칸 — 숫자를 적어야 저장 - check(); - }); - } - const reset = createButton({ - label: "기본값", - variant: "ghost", - onClick: () => { - fields.forEach((f) => f.set(f.fallback)); - check(); - }, - }); - reset.title = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀"; + for (const f of fields) f.field.input.addEventListener("input", check); - const title = document.createElement("p"); - title.className = "b07-settings__note"; - title.textContent = - kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`; const row = document.createElement("div"); row.className = "b07-scale__row"; row.append(...fields.map((f) => f.field.root)); - const buttons = document.createElement("div"); - buttons.className = "b07-scale__actions"; - buttons.append(reset, saveButton); - section.append(title, row, alertLine, buttons); + section.append(row, alertLine); + return { + read: () => { + const read = fields.map((f) => [f, f.read()] as const); + if (read.some(([, value]) => typeof value === "string")) return "invalid"; + const now = data.scales[kind]; + if (read.every(([f, value]) => value === (now[f.axis] ?? f.fallback))) return null; + return Object.fromEntries( + read.map(([f, v]) => [f.axis, scaleToSend(v as number, f.fallback)]), + ) as DrawingScaleSend; + }, + reset: () => { + fields.forEach((f) => f.set(f.fallback)); + check(); + }, + }; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 5c8e11761..367d118f0 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -214,8 +214,8 @@ color: var(--color-text-muted); } -/* 횡단 장 배치(153-1 · 212-3 · 280-5 ⚙ 횡단 모달) — 한 줄에 한 일: - 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비. */ +/* 횡단 장 배치(153-1 · 280-5 ⚙ 횡단 모달 · 523-1 그룹 상자) — 한 줄에 한 일: + 「장 배치」 배치 열 · 행 / 적용 · 「표」 표 배율 · 한 줄 안 같은 너비. */ .b07-cross-layout { display: flex; flex-direction: column; @@ -453,27 +453,71 @@ html.is-bm-route .b07-cad-stack > .cad-tabs { padding-block: 2px; } -/* ⚙ 설정 모달(280-5) — 안내 한 줄 · 횡단 배치 칸 */ +/* ⚙ 설정 모달(280-5 · 523-1) — 공용 모달 폭 절반 · 그룹 상자 · 맨 아래 단추 줄 하나 */ +.ui-modal.b07-settings { + width: min(360px, 92vw); +} + +/* 좁은 폭 — 제목 · 머리 단추 한 줄(줄바꿈 없음) */ +.ui-modal.b07-settings .ui-modal__top > h3 { + font-size: 16px; + white-space: nowrap; +} + +.ui-modal.b07-settings .ui-modal__top .ui-btn { + padding-inline: var(--spacing-8); + white-space: nowrap; +} + .b07-settings__note { - margin: 0 0 var(--spacing-8); + margin: 0; color: var(--color-text-secondary); font-size: 13px; } -.b07-settings .b07-cross-layout { +.b07-settings__group { display: flex; flex-direction: column; gap: var(--spacing-8); - min-width: 280px; + min-width: 0; + margin: 0; + padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); } -/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */ +.b07-settings__group > legend { + padding: 0 var(--spacing-4); + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; +} + +.b07-settings__actions { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.b07-settings__cancel { + margin-left: auto; +} + +/* 단추 줄 한 규격 — 안내선 단추(좌측 패널 작은 꼴)도 같은 키 · 좁은 폭에 맞춘 옆 여백 */ +.b07-settings__actions .ui-btn { + padding: var(--spacing-8) var(--spacing-12); + font-size: var(--text-body-sm); + border-radius: var(--radius-buttons); + white-space: nowrap; +} + +/* ⚙ 축척 칸(485-2) — X · Y(· 평면도) 칸 한 줄 */ .b07-scale { display: flex; flex-direction: column; gap: var(--spacing-8); - min-width: 280px; - margin-bottom: var(--spacing-8); } .b07-scale__row { @@ -495,12 +539,6 @@ html.is-bm-route .b07-cad-stack > .cad-tabs { font-size: 12px; } -.b07-scale__actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); -} - /* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */ .b07-drawing-button[data-fetch="error"] { border-left-color: var(--color-danger); diff --git a/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts b/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts index 7c3b2c307..ea898e63d 100644 --- a/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts +++ b/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts @@ -1,16 +1,11 @@ /* ============================================================================= * B07_DesignDetail_Scale_Check.ts - * ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글. + * ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 틀리면 까닭 글. * 서버 `checked_scales`(`_Router_Support_Scale.py`)와 같은 규칙 — 범위는 서버가 내려 줌. * ========================================================================== */ -/** 칸 하나 읽기 — 숫자 · null(자동) · 까닭 글(틀림). */ -export function readScaleAxis( - raw: string, - auto: boolean, - [low, high]: [number, number], -): number | null | string { - if (auto) return null; +/** 칸 하나 읽기 — 숫자 · 까닭 글(틀림). */ +export function readScaleAxis(raw: string, [low, high]: [number, number]): number | string { const text = raw.trim().replace(/,/g, ""); const value = Number(text); if (text === "" || !Number.isInteger(value) || value < low || value > high) { @@ -20,5 +15,5 @@ export function readScaleAxis( } /** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */ -export const scaleToSend = (value: number | null, fallback: number | null): number | null => +export const scaleToSend = (value: number, fallback: number): number | null => value === fallback ? null : value; diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index 228cc3d9e..5077e3c5c 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -1,6 +1,7 @@ -/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를 - * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · - * 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜. +/* 153-1 · 523-1 B07 횡단 장 배치 헬퍼 — 좌측 「횡단도」 카드 ⚙ 하나 · ⚙ 횡단 모달(그룹 상자 + * 「장 배치」 · 「표」 · 맨 아래 단추 줄 하나 · [확인] = 바뀐 것만 저장)을 Node 로 돌려 표준출력 JSON 한 벌로. + * TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · 공용 단추 · 입력칸 · 고르기칸은 진짜 + * (`ui_template_elements`) · 모달 겉틀 · 알림만 기록용 가짜. */ const fs = require("fs"); const path = require("path"); @@ -65,6 +66,14 @@ const byClass = (node, name) => all(node).filter((n) => n.classList.contains(nam globalThis.document = { createElement: (tag) => new FakeNode(tag) }; const toasts = []; +const modals = []; +const openModal = (opts) => { + const body = new FakeNode("div"); + const modal = { opts, body, open: true }; + modals.push(modal); + opts.mount(body, () => (modal.open = false), new FakeNode("div")); + return { close: () => (modal.open = false) }; +}; const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { @@ -74,6 +83,9 @@ const { loadTs } = makeLoader({ ...loadTs(path.join(UI, "ui_template_elements.ts")), showToast: (message, kind) => toasts.push([kind, message]), }; + if (request === "@ui/ui_template_modal") return { openModal }; + if (request === "./B07_DesignDetail_UI_PlanManage") return {}; + if (request === "./B07_DesignDetail_Api_Fetch") return {}; // 공용 카드(269) = 진짜 모듈 if (request === "@ui/ui_template_collapsible") return loadTs(path.join(UI, "ui_template_collapsible.ts")); @@ -90,20 +102,18 @@ const { loadTs } = makeLoader({ }, }); -const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs( - path.join(B07, "B07_DesignDetail_UI_Panels.ts"), -); +const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts")); +const { openDrawingSettings } = loadTs(path.join(B07, "B07_DesignDetail_UI_Settings.ts")); const out = {}; const settings = []; -const slot = new FakeNode("div"); -slot.className = "b07-cross-layout-slot"; +const drawings = [ + { 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 }, +]; 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 }, - ], + drawings, () => {}, undefined, false, @@ -119,74 +129,113 @@ out.card = { gears[0].click(); out.card.settings = settings; -// 장 — [이 장] 켜짐 · 값 바꿔 누름 -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), -}; - -// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용] -const scales = []; -const toastStart = toasts.length; -slot.replaceChildren( - buildCrossLayoutControls([4, 4], null, () => {}, { - scale: { value: 1, onApply: (value) => scales.push(value) }, - }), -); -const scaleInput = inputs()[2]; -out.scale = { - labels: byClass(slot, "ui-field__label").map((n) => n.textContent), - input: { - value: scaleInput.value, - min: scaleInput.attrs.min, - max: scaleInput.attrs.max, - step: scaleInput.attrs.step, +// ── ⚙ 횡단 모달(523-1) ───────────────────────────────────────────────────── +const tick = () => new Promise((r) => setTimeout(r, 0)); +const saved = []; +let current = drawings[1]; // cross_s1 = 1장 +const guideToggle = new FakeNode("button"); +guideToggle.textContent = "안내선 숨기기"; +const deps = { + drawings, + current: () => current, + canRedraw: () => false, + redraw: async () => false, + cross: { + layout: async () => ({ default: [4, 4], sheets: { 1: [3, 4] }, table_scale: 1 }), + save: (next, sheet) => saved.push([next, sheet]), + guideToggle, }, - buttons: buttons().map((n) => n.textContent), }; -scaleInput.value = "1.5"; -buttons()[2].click(); -scaleInput.value = "2.5"; -buttons()[2].click(); -out.scale.applied = scales; -out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]); +const open = async () => { + openDrawingSettings("cross", deps); + await tick(); + const modal = modals.at(-1); + const inputs = all(modal.body).filter((n) => n.tagName === "INPUT"); + const select = all(modal.body).find((n) => n.tagName === "SELECT"); + const footer = byClass(modal.body, "b07-settings__actions")[0]; + const btn = (label) => all(footer).find((n) => n.textContent === label); + const type = (input, value) => { + input.value = value; + (input.listeners.input || []).forEach((h) => h()); + }; + return { modal, inputs, select, footer, btn, type }; +}; -process.stdout.write(JSON.stringify(out)); +(async () => { + // 장 — 그룹 상자 둘 · 공용 칸 · 단추 줄 하나 + let m = await open(); + const legends = byClass(m.modal.body, "b07-settings__group").map( + (g) => g.children[0].textContent, + ); + const bodyButtons = all(m.modal.body).filter((n) => n.tagName === "BUTTON"); + out.sheet = { + legends, + labels: byClass(m.modal.body, "ui-field__label").map((n) => n.textContent), + inputs: m.inputs.map((n) => ({ + value: n.value, + type: n.attrs.type, + min: n.attrs.min, + max: n.attrs.max, + step: n.attrs.step ?? null, + cls: n.className, + })), + scope: { options: m.select.children.map((o) => o.textContent), value: m.select.value }, + footers: byClass(m.modal.body, "b07-settings__actions").length, + footerButtons: m.footer.children.map((n) => n.textContent), + buttonsOutsideFooter: bodyButtons.filter((n) => !all(m.footer).includes(n)).length, + }; + // 이 장만 — 2 × 5 + m.type(m.inputs[0], "2"); + m.type(m.inputs[1], "5"); + m.btn("확인").click(); + await tick(); + out.applySheet = { saved: [...saved], closed: !m.modal.open }; + // 모든 장 — 고르기만 바꿔도 씀(장마다 값 지움) + saved.length = 0; + m = await open(); + m.select.value = "all"; + m.btn("확인").click(); + await tick(); + out.applyAll = [...saved]; + // 범위 밖 = 칸 붉음 · [확인] 꺼짐 · 저장 없음 + saved.length = 0; + m = await open(); + m.type(m.inputs[0], "11"); + const confirm = m.btn("확인"); + out.badGrid = { + disabled: confirm.disabled, + error: m.inputs[0].className.includes("ui-input--error"), + }; + confirm.click(); + await tick(); + out.badGrid.saved = saved.length; + out.badGrid.open = m.modal.open; + // 표 배율 — 1.5 = 배율만 · 2.5 = 꺼짐 + m = await open(); + m.type(m.inputs[2], "2.5"); + out.badTable = m.btn("확인").disabled; + m.type(m.inputs[2], "1.5"); + m.btn("확인").click(); + await tick(); + out.applyTable = [...saved]; + // 장 아님(표준 횡단면도) — 고르기 = 모든 장 하나 · 손대지 않고 [확인] = 저장 없이 닫음 + saved.length = 0; + current = drawings[0]; + m = await open(); + out.notSheet = { + options: m.select.children.map((o) => o.textContent), + values: m.inputs.map((n) => n.value), + }; + m.btn("확인").click(); + await tick(); + out.notSheet.saved = saved.length; + out.notSheet.closed = !m.modal.open; + // [취소] = 저장 없이 닫음 + current = drawings[1]; + m = await open(); + m.type(m.inputs[0], "2"); + m.btn("취소").click(); + out.cancel = { saved: saved.length, closed: !m.modal.open }; + + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_280_5_b07_settings.cjs b/resources/tester/helper_280_5_b07_settings.cjs index 5c00e358d..e4fe87368 100644 --- a/resources/tester/helper_280_5_b07_settings.cjs +++ b/resources/tester/helper_280_5_b07_settings.cjs @@ -60,6 +60,10 @@ class FakeNode { kids.forEach((k) => (k.parent = this)); this.children.unshift(...kids); } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; + } replaceWith(node) { const at = this.parent.children.indexOf(this); node.parent = this.parent; @@ -97,16 +101,16 @@ const calls = []; let failConfirm = false; const SCALES = { scales: { - longitudinal: { x: 1000, y: 200, plan: null }, + longitudinal: { x: 1000, y: 200, plan: 5000 }, cross: { x: 100, y: 100 }, - mass_haul: { x: null, y: 50000 }, - development: { x: null, y: null }, + mass_haul: { x: 2000, y: 50000 }, + development: { x: 50, y: 50 }, }, defaults: { - longitudinal: { x: 1000, y: 200, plan: null }, + longitudinal: { x: 1000, y: 200, plan: 5000 }, cross: { x: 100, y: 100 }, - mass_haul: { x: null, y: 50000 }, - development: { x: null, y: null }, + mass_haul: { x: 2000, y: 50000 }, + development: { x: 50, y: 50 }, }, ranges: { longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] }, @@ -141,21 +145,27 @@ const STUBS = { const setError = (msg) => (input.error = msg ?? null); return { root, input, setError }; }, + createSelectField: (opts) => { + const root = new FakeNode("div"); + const label = new FakeNode("span"); + label.className = "ui-field__label"; + label.textContent = opts.label; + const select = new FakeNode("select"); + opts.options.forEach((o) => { + const option = new FakeNode("option"); + option.textContent = o.text; + option.value = o.value; + select.append(option); + }); + select.value = opts.value ?? opts.options[0]?.value; + root.append(label, select); + return { root, select, setOptions() {} }; + }, showToast: (message, kind) => toasts.push([kind, message]), showLoadingOverlay: () => calls.push(["overlay+"]), hideLoadingOverlay: () => calls.push(["overlay-"]), showConfirmDialog: async (message) => (calls.push(["ask", message]), answer), }, - "@ui/ui_template_checkbox": { - createCheckbox: (text) => { - const root = new FakeNode("label"); - root.className = "ui-checkbox"; - const input = new FakeNode("input"); - input.checked = false; - root.append(input); - return { root, input }; - }, - }, "@ui/ui_template_modal": { openModal: (opts) => { const body = new FakeNode("div"); @@ -280,55 +290,85 @@ const drawings = [ let redraws = 0; const saved = []; const guideToggle = button({ label: "안내선 숨기기" }); + const closedCalls = []; const deps = { drawings, current: () => current, canRedraw: () => true, redraw: async () => (redraws++, true), - plan: { projectId: "p1", onClosed: () => {} }, + plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) }, cross: { layout: async () => ({ default: [4, 4], sheets: { 1: [3, 2] }, table_scale: 1.2 }), save: (next, sheet) => saved.push([next, sheet]), guideToggle, }, }; + const tick = () => new Promise((r) => setTimeout(r, 0)); const texts = (node) => all(node) .map((n) => n._text) .filter(Boolean); + const legends = (node) => + byClass(node, "b07-settings__group").map((g) => g.children[0].textContent); + const footerOf = (modal) => byClass(modal.body, "b07-settings__actions"); + const footerBtn = (modal, label) => + all(footerOf(modal)[0]).find((n) => n.tagName === "BUTTON" && n.textContent === label); + const type = (input, value) => { + input.value = value; + (input.listeners.input || []).forEach((h) => h()); + }; + let autoBoxes = 0; // 「자동」 체크(523-1 걷음) — 모든 모달에서 셈 + const countAuto = (modal) => + (autoBoxes += all(modal.body).filter((n) => n.attrs.type === "checkbox").length); + openDrawingSettings("cross", deps); - await new Promise((r) => setTimeout(r, 0)); + await tick(); + await tick(); const cross = modals.at(-1); - const layoutNode = byClass(cross.body, "b07-cross-layout")[0]; - const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT"); + countAuto(cross); + const layoutBoxes = byClass(cross.body, "b07-cross-layout"); + const crossInputs = layoutBoxes.flatMap((n) => all(n).filter((k) => k.tagName === "INPUT")); out.crossModal = { title: cross.opts.title, - labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent), + legends: legends(cross.body), + labels: layoutBoxes.flatMap((n) => byClass(n, "ui-field__label")).map((n) => n.textContent), values: crossInputs.map((n) => n.value), - scaleTitle: texts(byClass(cross.body, "b07-scale")[0])[0], - buttons: all(layoutNode) - .filter((n) => n.tagName === "BUTTON") - .map((n) => [n.textContent, n.disabled]), - guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle, + footers: footerOf(cross).length, + footerButtons: footerOf(cross)[0].children.map((n) => n.textContent), + buttonsInGroups: byClass(cross.body, "b07-settings__group").flatMap((g) => + all(g).filter((n) => n.tagName === "BUTTON"), + ).length, head: cross.opts.actions.map((n) => [n.textContent, n.disabled]), notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")), }; - crossInputs[0].value = "2"; - all(layoutNode) - .find((n) => n.textContent === "이 장") - .click(); - out.crossSave = { saved, closed: !cross.open }; + type(crossInputs[0], "2"); + footerBtn(cross, "확인").click(); + await tick(); + out.crossSave = { + saved, + closed: !cross.open, + scaleCalls: calls.filter((c) => c[0] === "scales").length, + }; openDrawingSettings("structure", deps); const structure = modals.at(-1); + countAuto(structure); out.structureModal = { title: structure.opts.title, notes: texts(structure.body), + legends: legends(structure.body), + footerButtons: footerOf(structure)[0].children.map((n) => n.textContent), head: structure.opts.actions.map((n) => [n.textContent, n.disabled, n.title]), }; + footerBtn(structure, "확인").click(); + out.structureModal.closed = !structure.open; openDrawingSettings("longitudinal", deps); - out.longModal = { title: modals.at(-1).opts.title, bodyKids: modals.at(-1).body.children.length }; + out.longModal = { + title: modals.at(-1).opts.title, + legends: legends(modals.at(-1).body), + footers: footerOf(modals.at(-1)).length, + }; current = drawings[1]; // plan_k openDrawingSettings("plan", deps); @@ -338,64 +378,52 @@ const drawings = [ head: plan.controls.actions.map((n) => [n.textContent, n.disabled]), }; plan.controls.actions[0].click(); - await new Promise((r) => setTimeout(r, 0)); + await tick(); out.planRedraw = { redraws, closed: !plan.open }; current = drawings[3]; // 표준 횡단면도도 횡단 묶음 openDrawingSettings("cross", deps); out.standardInCross = !modals.at(-1).opts.actions[0].disabled; - // ── 축척 칸(485-2) ───────────────────────────────────────────────────── - const closedCalls = []; - const scaleDeps = { ...deps, plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) } }; - const tick = () => new Promise((r) => setTimeout(r, 0)); + // ── 축척 칸(485-2 · 523-1 자동 없음 · 저장 = 모달 [확인]) ───────────────── + closedCalls.length = 0; const scaleOf = async (kind) => { - openDrawingSettings(kind, scaleDeps); + openDrawingSettings(kind, deps); await tick(); const modal = modals.at(-1); + countAuto(modal); const section = byClass(modal.body, "b07-scale")[0]; - const inputs = all(section).filter( - (n) => n.tagName === "INPUT" && !n.parent.classList.contains("ui-checkbox"), - ); - const autos = byClass(section, "ui-checkbox").map((n) => n.children[0]); - const btn = (label) => - all(section).find((n) => n.tagName === "BUTTON" && n.textContent === label); - const type = (input, value) => { - input.value = value; - input.listeners.input.forEach((h) => h()); - }; - const toggle = (box) => { - box.checked = !box.checked; - box.listeners.change.forEach((h) => h()); - }; + const inputs = all(section).filter((n) => n.tagName === "INPUT"); + const btn = (label) => footerBtn(modal, label); const state = () => ({ values: inputs.map((n) => n.value), errors: inputs.map((n) => n.error ?? null), disabled: inputs.map((n) => !!n.disabled), - autos: autos.map((n) => n.checked), - save: !btn("축척 저장").disabled, + save: !btn("확인").disabled, }); - return { modal, section, inputs, autos, btn, type, toggle, state }; + return { modal, section, inputs, btn, state }; }; const scale = {}; current = drawings[2]; // longitudinal let s = await scaleOf("longitudinal"); scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent); + scale.longLegend = legends(s.modal.body); + scale.longFooter = footerOf(s.modal)[0].children.map((n) => n.textContent); scale.longStart = s.state(); - s.type(s.inputs[0], "50"); + type(s.inputs[0], "50"); scale.longLow = s.state(); - s.type(s.inputs[0], "500.5"); + type(s.inputs[0], "500.5"); scale.longFrac = s.state(); - s.type(s.inputs[0], "500"); + type(s.inputs[0], "500"); scale.longOk = s.state(); calls.length = 0; answer = false; - s.btn("축척 저장").click(); + s.btn("확인").click(); await tick(); scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length }; answer = true; calls.length = 0; - s.btn("축척 저장").click(); + s.btn("확인").click(); await tick(); await tick(); scale.save = { @@ -405,66 +433,53 @@ const drawings = [ closed: [...closedCalls], }; s = await scaleOf("longitudinal"); - s.type(s.inputs[1], "300"); + type(s.inputs[1], "300"); s.btn("기본값").click(); scale.reset = s.state(); calls.length = 0; - const toastAt = toasts.length; - s.btn("축척 저장").click(); + s.btn("확인").click(); await tick(); - scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) }; - // 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장 + scale.same = { calls: calls.length, open: s.modal.open }; + // 평면 위치도 칸(521-2) — 처음 1/5,000 · 1/500 밑 = 붉음 · 1/3,000 저장 s = await scaleOf("longitudinal"); - scale.planStart = s.state(); - s.toggle(s.autos[0]); - s.type(s.inputs[2], "400"); + type(s.inputs[2], "400"); scale.planLow = s.state(); - s.type(s.inputs[2], "3000"); + type(s.inputs[2], "3000"); calls.length = 0; - s.btn("축척 저장").click(); + s.btn("확인").click(); await tick(); await tick(); scale.planSent = calls.find((c) => c[0] === "scales")?.[1]; + // [취소] = 저장 없이 닫음 + s = await scaleOf("longitudinal"); + type(s.inputs[0], "500"); + calls.length = 0; + s.btn("취소").click(); + scale.cancelButton = { calls: calls.length, open: s.modal.open }; s = await scaleOf("mass_haul"); scale.massStart = s.state(); - s.toggle(s.autos[0]); - scale.massManual = s.state(); - s.type(s.inputs[0], "2000"); - scale.massTyped = s.state(); + type(s.inputs[0], "3000"); calls.length = 0; - s.btn("축척 저장").click(); + s.btn("확인").click(); await tick(); await tick(); scale.massSent = { ask: calls.filter((c) => c[0] === "ask").length, sent: calls.find((c) => c[0] === "scales")?.[1], }; - s = await scaleOf("mass_haul"); - s.toggle(s.autos[0]); - s.toggle(s.autos[0]); - scale.massBack = s.state(); s = await scaleOf("development"); scale.dev = s.state(); - openDrawingSettings("structure", scaleDeps); + openDrawingSettings("structure", deps); scale.structure = byClass(modals.at(-1).body, "b07-scale").length; + scale.autoBoxes = autoBoxes; out.scale = scale; const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts")); - out.readAxis = [ - ["1,000", false], - [" 99 ", false], - ["10001", false], - ["12.5", false], - ["", false], - ["abc", false], - ["", true], - ].map(([raw, auto]) => Check.readScaleAxis(raw, auto, [100, 10000])); - out.toSend = [ - Check.scaleToSend(200, 200), - Check.scaleToSend(500, 200), - Check.scaleToSend(null, null), - ]; + out.readAxis = ["1,000", " 99 ", "10001", "12.5", "", "abc"].map((raw) => + Check.readScaleAxis(raw, [100, 10000]), + ); + out.toSend = [Check.scaleToSend(200, 200), Check.scaleToSend(500, 200)]; // ── 저장 묶음 ──────────────────────────────────────────────────────────── const tabsOf = (list, activeId) => ({ diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py index af9d9d6ec..73b88ef3f 100644 --- a/resources/tester/test_153_1_b07_cross_layout.py +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- -"""153-1 B07 횡단 장 배치 칸. +"""153-1 · 523-1 B07 횡단 장 배치 — ⚙ 횡단 모달. 못박는 것 - 배치 칸 자리 = 「횡단도」 카드 ⚙ 모달(280-5) — 카드엔 ⚙ 하나 · 배치 칸 없음. - - 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장]. - - 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만. + - 그룹 상자 「장 배치」(배치 열 · 행 · 적용) · 「표」(표 배율) · 공용 작은 칸(ui-input--sm). + - 단추 줄 = 맨 아래 하나([안내선 숨기기] [취소] [확인]) · 그 밖 단추 없음. + - [확인] = 바뀐 것만 저장 — 이 장만 · 모든 장(장마다 값 지움) · 표 배율만 · 손 안 대면 저장 없이 닫음. + - 범위 밖 = 칸 붉음 + [확인] 꺼짐 · 장이 아니면 적용 = 모든 장 하나 · [취소] = 그대로 닫음. -Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. +Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. """ import json @@ -38,37 +40,39 @@ def test_횡단도_카드는_설정_단추만(out): assert card["settings"] == ["cross"] -def test_공용_입력칸과_작은_단추(out): +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 sheet["legends"] == ["장 배치", "표"] + assert sheet["labels"] == ["배치 열", "행", "적용", "표 배율"] + assert [i["value"] for i in sheet["inputs"]] == ["3", "4", "1"] # 지금 장(1장) 값 + for field in sheet["inputs"][:2]: 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"] + table = sheet["inputs"][2] + assert (table["min"], table["max"], table["step"]) == ("0.5", "2", "0.1") + assert all("ui-input--sm" in field["cls"] for field in sheet["inputs"]) + assert sheet["scope"] == {"options": ["이 장만", "모든 장"], "value": "sheet"} + assert sheet["footers"] == 1 and sheet["buttonsOutsideFooter"] == 0 + assert sheet["footerButtons"] == ["안내선 숨기기", "취소", "확인"] -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): + assert out["applySheet"] == { + "saved": [[{"default": [4, 4], "sheets": {"1": [2, 5]}, "table_scale": 1}, 1]], + "closed": True, + } + assert out["applyAll"] == [[{"default": [3, 4], "sheets": {}, "table_scale": 1}, 1]] + assert out["applyTable"] == [ + [{"default": [4, 4], "sheets": {"1": [3, 4]}, "table_scale": 1.5}, 1] + ] -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"] - - -def test_표_배율_칸(out): - scale = out["scale"] - assert scale["labels"] == ["배치 열", "행", "표 배율"] - assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"} - assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"] - assert scale["applied"] == [1.5] - assert scale["toasts"] == ["error"] # 2.5 = 범위 밖 +def test_범위_밖_장_아님_취소(out): + assert out["badGrid"] == {"disabled": True, "error": True, "saved": 0, "open": True} + assert out["badTable"] is True + assert out["notSheet"] == { + "options": ["모든 장"], + "values": ["4", "4", "1"], + "saved": 0, + "closed": True, + } + assert out["cancel"] == {"saved": 0, "closed": True} diff --git a/resources/tester/test_180_3_b07_guide_toggle.py b/resources/tester/test_180_3_b07_guide_toggle.py index a8c0899c2..1dfdf6fa5 100644 --- a/resources/tester/test_180_3_b07_guide_toggle.py +++ b/resources/tester/test_180_3_b07_guide_toggle.py @@ -68,8 +68,8 @@ def test_페이지_잇기_토스트_거름_장만(): assert "if (guideShown() && guide?.marks?.length) {" in page # 숨김 = 넘침 토스트 없음 assert "cad.setGuideVisible(visible)" in page assert "if (!guideShown()) cad.setGuideVisible(false);" in page # 다시 세운 화면에 이어 줌 - # 배치 칸 줄 끝 = ⚙ 횡단 모달(280-5) - assert "controls.append(deps.cross.guideToggle);" in _read( + # ⚙ 횡단 모달(280-5) 맨 아래 단추 줄(523-1) + assert "if (cross) footer.append(deps.cross.guideToggle);" in _read( B07 / "B07_DesignDetail_UI_Settings.ts" ) assert "guideToggle.disabled = !isCrossSheet(drawing);" in page # 장만 diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py index 29654ae21..8b4e28e49 100644 --- a/resources/tester/test_212_3_b07_cross_layout_buttons.py +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -2,8 +2,8 @@ """212-3 B07 좌측 「횡단도」 배치 칸 — 단추 · 숫자 칸 한 규격 · 한 줄에 한 일 · 남는 검은 선 판정. 못박는 것 - - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 - · [안내선 숨기기](부르는 쪽). + - 줄 = 「배치 열 · 행」 · 「적용」 · 「표 배율」(⚙ 횡단 모달 그룹 상자 · 523-1) + · 단추([안내선 숨기기] [취소] [확인]) = 맨 아래 단추 줄 하나. - 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비. - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. - 인쇄 칸선 없음(526-1 · 옛 「남는 검은 선」) — 칸은 안내선 겹판(단면마다 점선)이라 @@ -53,12 +53,11 @@ def test_한_줄에_한_일_같은_너비(): assert "flex-wrap" not in box assert _decl(_block(".b07-cross-layout__row > *"), "flex") == "1 1 0" assert "width: 48px" not in CSS - assert 'line.className = "b07-cross-layout__row"' in PANELS - assert "row(cols.root, rows.root)" in PANELS - assert re.search(r"row\(\s*action\(\"이 장\"[^;]*action\(\"모든 장\"", PANELS, re.S) - assert "box.append(row(scale.root, button));" in PANELS - # 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭) · ⚙ 횡단 모달(280-5) - assert "controls.append(deps.cross.guideToggle);" in SETTINGS + assert 'row.className = "b07-cross-layout__row"' in SETTINGS + assert "line(cols.root, rows.root), line(scope.root)" in SETTINGS + assert "line(table.root)" in SETTINGS + # 안내선 단추 = 맨 아래 단추 줄(523-1) · ⚙ 횡단 모달(280-5) + assert "if (cross) footer.append(deps.cross.guideToggle);" in SETTINGS def test_인쇄_칸선_없음_칸은_안내선_겹판(): diff --git a/resources/tester/test_280_5_b07_drawing_scales.py b/resources/tester/test_280_5_b07_drawing_scales.py index 558dc5d2e..d08cc82ea 100644 --- a/resources/tester/test_280_5_b07_drawing_scales.py +++ b/resources/tester/test_280_5_b07_drawing_scales.py @@ -1,6 +1,6 @@ """485-1 B07 도면 X · Y 축척(요구 280-5) — 프로젝트마다 `drawing_scales.json`. -값 없음 = 지금 결과 그대로 · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 · +값 없음 = 기본 지정값(자동 없음 · 523-1) · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 · 토적 · 전개도 · 범위 밖 거절 · 바꾼 종류만 저장본 풂. """ @@ -218,8 +218,8 @@ MASS_HAUL = { def test_토적_가로_세로(project: Path) -> None: - auto = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") - assert "SCALE H=1:500 V=1:50,000" in _texts(auto) # 40 m = 자동 1/500 + base = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") + assert "SCALE H=1:2,000 V=1:50,000" in _texts(base) # 기본 = 지정값(자동 없음 · 523-1) _set(project, {"mass_haul": {"x": 1000, "y": 100000}}) fixed = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") assert "SCALE H=1:1,000 V=1:100,000" in _texts(fixed) @@ -231,8 +231,8 @@ def test_토적_가로_세로(project: Path) -> None: def test_전개도_가로_세로(project: Path) -> None: rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(3)] sheet = dev.development_sheets(rows)[0] - auto = build_development_drawing(sheet["id"], sheet, 20.0) - assert any(text.startswith("S=1:") for text in _texts(auto)) + base = build_development_drawing(sheet["id"], sheet, 20.0) + assert "S=1:50" in _texts(base) # 기본 = 가로 · 세로 1/50 지정값(523-1) _set(project, {"development": {"x": 200, "y": 100}}) fixed = build_development_drawing(sheet["id"], sheet, 20.0) assert "H=1:200 V=1:100" in _texts(fixed) @@ -244,7 +244,7 @@ def test_전개도_가로_세로(project: Path) -> None: def test_범위_검사() -> None: assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}} - # 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동 + # 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 기본 assert checked_scales({"longitudinal": {"plan": 3000}}) == { "longitudinal": {"x": None, "y": None, "plan": 3000} } @@ -294,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None: drawings = _read_manifest(project)["drawings"] assert not drawings["longitudinal_1"]["confirmed"] assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"] - assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None} + assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": 5000} # 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫 invalidated, stale = write_drawing_scales( project, project / "none.json", {"cross": {"x": 100, "y": 50}} @@ -321,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch) assert not drawing_scales_path(project).exists() # 거절 = 안 씀 put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}}) assert put.status_code == 200 - assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None} + assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": 5000} assert put.json()["invalidated"] == {} diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index ee9be7a36..4b17f1c08 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -6,6 +6,8 @@ - ⚙ 누름 = 카드 접힘 그대로 · 도면 안 엶(Enter 도) · 제목 누름 · 단추 누름은 그대로. - 모달 — 횡단 = 배치 칸(지금 장 값) + 표 배율 + 안내선 · 평면도 = 평면도 관리 모달 · 구조물 = 축척 없음 줄 · 머리 [원본에서 다시 그리기] = 그 묶음 도면을 연 때만 켬(표준 횡단면도 = 횡단 묶음). + - 523-1 — 그룹 상자(legend) · 맨 아래 단추 줄 하나([기본값] [취소] [확인]) · 「자동」 체크 없음 · + [확인] = 바뀐 것만 저장 · 축척 기본 = 지정값(종단 평면도 1/5,000 · 토적 X 1/2,000 · 전개도 1/50). - 저장 묶음 — 고친 탭(●) + 지금 탭만 confirm · 고친 것 없으면 요청 0 · 실패 / 도각 편집 = 거짓(머묾) · 원본 되돌림 = 물음 → invalidate → 다시 실음 · [취소] = 그대로. - 페이지 — [현재 도면 확정] · [수정] · 자동 B08 이동 · 「모든 횡단 확정」 잠금 없음 · 읽기 전용 안 묶음 · @@ -77,24 +79,25 @@ def test_설정_누름은_접힘_도면_열기_안_일으킴(out): def test_횡단_모달은_배치_칸_표_배율_안내선(out): cross = out["crossModal"] assert cross["title"] == "횡단도 설정" - assert cross["labels"] == ["배치 열", "행", "표 배율"] + assert cross["legends"] == ["장 배치", "표", "축척 — 횡단도 장(표준 횡단면도 뺌)"] + assert cross["labels"] == ["배치 열", "행", "적용", "표 배율"] assert cross["values"] == ["3", "2", "1.2"] # 지금 장(1장) 값 · 표 배율 - assert [b[0] for b in cross["buttons"]] == ["이 장", "모든 장", "배율 적용", "안내선 숨기기"] - assert cross["guideLast"] is True + assert cross["footers"] == 1 and cross["buttonsInGroups"] == 0 # 단추 줄 하나(523-1) + assert cross["footerButtons"] == ["안내선 숨기기", "기본값", "취소", "확인"] assert cross["head"] == [["원본에서 다시 그리기", False]] assert cross["notes"] == ["지금 도면: cross_s1"] - assert cross["scaleTitle"] == "축척 — 횡단도 장(표준 횡단면도 뺌)" saved = out["crossSave"] - assert saved["closed"] is True + assert saved["closed"] is True and saved["scaleCalls"] == 0 # 축척 그대로 = 안 보냄 assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]] def test_구조물_종단_평면도_모달(out): structure = out["structureModal"] assert structure["title"] == "구조물 도면 설정" - assert structure["notes"] == ["M02 그림 — 축척 없음"] + assert structure["legends"] == ["축척"] and "M02 그림 — 축척 없음" in structure["notes"] + assert structure["footerButtons"] == ["취소", "확인"] and structure["closed"] is True assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐 - assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 1} # 축척 칸(485-2) + assert out["longModal"] == {"title": "종단도 설정", "legends": ["축척 — 종단도"], "footers": 1} assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]} assert out["planRedraw"] == {"redraws": 1, "closed": True} assert out["standardInCross"] is True @@ -223,16 +226,18 @@ RANGE = "1/100~1/10,000 사이 정수" def test_485_2_축척_칸_판정(out): - assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE, None] # 자동 = null - assert out["toSend"] == [None, 500, None] # 기본과 같으면 null + assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE] + assert out["toSend"] == [None, 500] # 기본과 같으면 null def test_485_2_종단_축척_저장(out): scale = out["scale"] assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"] - assert scale["longStart"]["values"] == ["1000", "200", ""] - assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2) - for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐 + assert scale["longLegend"] == ["축척 — 종단도"] + assert scale["longFooter"] == ["기본값", "취소", "확인"] + assert scale["longStart"]["values"] == ["1000", "200", "5000"] # 평면도 = 지정값(523-1) + assert scale["longStart"]["disabled"] == [False, False, False] + for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + [확인] 꺼짐 assert scale[bad]["errors"] == [RANGE, None, None] assert scale[bad]["save"] is False assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True @@ -243,27 +248,23 @@ def test_485_2_종단_축척_저장(out): assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null assert save["open"] is False assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면 - assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만 - assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]} - # 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장 - assert scale["planStart"]["disabled"] == [False, False, True] + assert scale["reset"]["values"] == ["1000", "200", "5000"] # [기본값] = 칸만 + assert scale["same"] == {"calls": 0, "open": False} # 바뀐 것 없음 = 닫기만 assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"] assert scale["planLow"]["save"] is False assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}} + assert scale["cancelButton"] == {"calls": 0, "open": False} -def test_485_2_토적_전개도_자동(out): +def test_523_1_토적_전개도_지정값_자동_없음(out): scale = out["scale"] assert scale["massStart"] == { - "values": ["", "50000"], + "values": ["2000", "50000"], "errors": [None, None], - "disabled": [True, False], - "autos": [True], + "disabled": [False, False], "save": True, } - assert scale["massManual"]["errors"][0] and scale["massManual"]["save"] is False # 빈 칸 - assert scale["massTyped"]["save"] is True - assert scale["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 2000, "y": None}}} - assert scale["massBack"]["autos"] == [True] and scale["massBack"]["save"] is True - assert scale["dev"]["autos"] == [True, True] and scale["dev"]["disabled"] == [True, True] + assert scale["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 3000, "y": None}}} + assert scale["dev"]["values"] == ["50", "50"] and scale["dev"]["disabled"] == [False, False] assert scale["structure"] == 0 # 구조물 = 축척 칸 없음 + assert scale["autoBoxes"] == 0 # 「자동」 체크 = 모든 모달에서 0 diff --git a/resources/tester/test_b07_long_sheet.py b/resources/tester/test_b07_long_sheet.py index de7f936bf..7955078bd 100644 --- a/resources/tester/test_b07_long_sheet.py +++ b/resources/tester/test_b07_long_sheet.py @@ -135,11 +135,11 @@ def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None ) assert not plan_box_overlaps(longitudinal, sheet["graph"], (x0, y0, *sheet["bbox"][2:])) assert f"{number}/{len(sheets)} 장" in sheet["texts"] - assert any(text.startswith("평면도 S=1/") for text in sheet["texts"]) + assert "평면도 S=1/5,000" in sheet["texts"] # 기본 지정값(자동 없음 · 523-1) assert sheet["bold"] # 이 장 구간 굵게 lows.append(min(x for x, _y in sheet["bold"]) - box[0]) - # 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감 - assert lows == sorted(lows) and len(set(lows)) == len(lows) + # 지정 축척 = 장마다 이 장 구간을 칸 가운데로 — 굵은 구간 왼끝이 칸 안 + assert all(0.0 <= low <= 150.0 for low in lows) def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None: diff --git a/resources/tester/test_b07_masshaul_basin.py b/resources/tester/test_b07_masshaul_basin.py index 84b5665d2..a67998031 100644 --- a/resources/tester/test_b07_masshaul_basin.py +++ b/resources/tester/test_b07_masshaul_basin.py @@ -123,8 +123,8 @@ def test_mass_haul_scale_follows_route_length(): def test_mass_haul_curve_uses_paper_scale(): - """유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m.""" - mm_h = 1000.0 / auto_scale_h(40.0) + """유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 가로 = 기본 지정값 1/2,000(523-1) -> 0.5mm/m.""" + mm_h = 1000.0 / 2000 drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") curve = _lines(drawing, "b08-masshaul-curve") assert curve[0][0] == (0.0, 0.0) @@ -143,7 +143,7 @@ def test_mass_haul_balloon_texts(): assert "사토 1" in labels assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점 assert "유 토 곡 선" in labels - assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척 + assert "SCALE H=1:2,000 V=1:50,000" in labels # 기본 지정값(자동 없음 · 523-1) def test_mass_haul_table_marks_match_delivery_form(): @@ -189,7 +189,7 @@ def test_mass_haul_band_chords_sit_at_stored_levels(): """경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다.""" drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") chords = _lines(drawing, "b08-masshaul-band") - mm_h = 1000.0 / auto_scale_h(40.0) + mm_h = 1000.0 / 2000 # 기본 지정값(523-1) assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0 assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥ diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index 580d2d762..a74de124d 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -1,6 +1,7 @@ /* ============================================================================= * ui_template_modal.ts * 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움. + * 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 닫음(칸 끌기로 안 닫힘). * M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임. * 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh). * 안 넘긴 창은 지금 꼴 그대로. @@ -49,7 +50,11 @@ export function openModal(opts: ModalOptions): { close: () => void } { className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(), children: [dialog], }); - backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask()); + // 바깥 누르기 = 누름 · 뗌 둘 다 바깥일 때만 — 칸 안에서 끌어 바깥에서 떼면(그 반대도) 안 닫음 + let outside = false; + backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop)); + backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop)); + backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask()); opts.mount(body, close, actions); // 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)