diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 5fc86b60a..12561432e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46) + // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) const extras = layer === "system" ? { - 옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null, + 옵션: section(options.title, options.body), 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index 0d0ec7b75..a91a2578f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -1,56 +1,29 @@ /* ============================================================================= * M02_SO_Api.ts - * 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py` - * (마스터 템플릿 `구조물정의.json` 「표시」 · 「그룹」 칸 · PLAN 48-1) + * 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48) * ========================================================================== */ import { call } from "../M02_MasterTemplete_Api_Fetch"; +import type { StructDef } from "./M02_SO_Def_Model"; -export interface OptionKind { - type_id: string; - group: string; - name: string; - /** 거짓이면 B05 선택지에 안 나오는 종류 */ - enabled: boolean; - options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[]; +/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */ + +/** 지난 판 한 줄 */ +export interface DefVersion { + 판: number; + 저장일: string; } -/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */ -export interface OptionsDoc { - 판: string; - 표시: Record>; - 종류: OptionKind[]; -} +/** 정의 한 벌 — 읽기 = 로그인 누구나 */ +export const fetchStructureDef = (): Promise => call("/structure-definition"); -export const fetchStructureOptions = (): Promise => - call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자 +/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */ +export const saveStructureDef = (판: number, 정의: StructDef): Promise => + call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) }); -/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => - call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); +/** 지난 판 목록(`구조물정의_이력/판_N.json`) */ +export const fetchStructureDefHistory = (): Promise => + call("/structure-definition/history"); -/* --- 옵션 그룹(PLAN 46-2 · 구조물 정의 「그룹」 칸 · 시스템 관리자만) --- */ - -/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */ -export interface OptionGroupDef { - id: string; - 이름: string; - 옵션: string[]; -} - -/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */ -export interface GroupsDoc { - 판: string; - 그룹: Record; - 종류: OptionKind[]; -} - -export const fetchStructureOptionGroups = (): Promise => - call("/structure-option-groups"); - -/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptionGroups = ( - 판: string, - 그룹: GroupsDoc["그룹"], -): Promise => - call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) }); +export const fetchStructureDefAt = (판: number): Promise => + call(`/structure-definition/history/${판}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts new file mode 100644 index 000000000..26899fdc1 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * M02_SO_Def.ts + * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) · + * 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) · + * 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄). + * 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1). + * ========================================================================== */ + +import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; +import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; +import { + fetchStructureDef, + fetchStructureDefAt, + fetchStructureDefHistory, + saveStructureDef, + type DefVersion, +} from "./M02_SO_Api"; +import { buildLayoutBlock } from "./M02_SO_Def_Layout"; +import { + baseShown, + clone, + problems, + stepFamily, + stepType, + type StructDef, +} from "./M02_SO_Def_Model"; +import { buildOptionsBlock } from "./M02_SO_Def_Options"; +import { buildRulesBlock } from "./M02_SO_Def_Rules"; +import { checkBox, textBox } from "./M02_SO_Def_Ui"; +import { buildGroupBlock } from "./M02_SO_Groups"; +import { to } from "./M02_SO_Text"; + +export interface DefScreenOptions { + /** 시스템 관리자 — 아니면 읽기만 */ + isAdmin: boolean; + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ +function tidy(def: StructDef): StructDef { + const out = clone(def); + for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record[]) + for (const [k, v] of Object.entries(part)) + if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k]; + return out; +} + +/** 작은 단추 */ +function smallButton( + label: string, + run: () => void, + off = false, + variant: "ghost" | "danger" = "ghost", +): HTMLButtonElement { + const b = createButton({ label, variant, size: "sm" }); + b.disabled = off; + b.addEventListener("click", run); + return b; +} + +/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */ +export async function openDefScreen(opt: DefScreenOptions): Promise { + let def: StructDef; + try { + def = await fetchStructureDef(); + } catch (error) { + showToast(to("Load_Failed", why(error)), "error"); + return false; + } + drawDef(def, opt); + return true; +} + +function drawDef(first: StructDef, opt: DefScreenOptions): void { + /** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */ + let base = first; + let work = clone(first); + let past: StructDef | null = null; + let dirty = false; + let current: string | null = null; + const open = new Set(); // 선택지를 펼친 옵션 + let versions: DefVersion[] | null = null; + + const shown = (): StructDef => past ?? work; + const canEdit = (): boolean => opt.isAdmin && !past; + const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } }); + const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } }); + const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } }); + const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } }); + const historyBox = el("div", { + className: "m02-sod__history", + attrs: { "data-sod-history": "" }, + }); + + const changed = (): void => { + dirty = true; + sync(); + paint(); + }; + + const paintKinds = (): void => { + const d = shown(); + const edit = canEdit(); + const parts: HTMLElement[] = []; + const families = Object.keys(d.구조물군); + families.forEach((g, gi) => { + parts.push( + el("div", { + className: "m02-sod__family", + attrs: { "data-sod-family": g }, + children: [ + textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, { + edit, + attr: "data-sod-family-name", + }), + ...(edit + ? [ + smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0), + smallButton( + to("GroupDown"), + () => stepFamily(d, g, 1) && changed(), + gi === families.length - 1, + ), + ] + : []), + ], + }), + ); + const kinds = d.types.filter((t) => t.group === g); + kinds.forEach((t, ti) => { + const off = t.enabled === false; + const groups = d.그룹[t.type_id]?.length ?? 0; + const b = el("button", { + className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`, + attrs: { type: "button", "data-sod-kind": t.type_id }, + children: [ + el("span", { text: t.name }), + ...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []), + ...(off ? [createTag(to("Off"), "neutral")] : []), + ], + }); + b.addEventListener("click", () => { + current = t.type_id; + paint(); + }); + parts.push( + el("div", { + className: "m02-sod__kindrow", + children: [ + b, + ...(edit + ? [ + smallButton( + to("GroupUp"), + () => stepType(d, t.type_id, -1) && changed(), + ti === 0, + ), + smallButton( + to("GroupDown"), + () => stepType(d, t.type_id, 1) && changed(), + ti === kinds.length - 1, + ), + ] + : []), + ], + }), + ); + }); + }); + kindList.replaceChildren(...parts); + }; + + const paintDetail = (): void => { + const d = shown(); + const type = d.types.find((t) => t.type_id === current); + if (!type) { + detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); + return; + } + const edit = canEdit(); + const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); + detail.replaceChildren( + el("div", { + className: "m02-sod__kindhead", + children: [ + textBox(type.name, (v) => (type.name = v), changed, { + edit, + attr: "data-sod-kind-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: type.type_id }), + checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), { + edit, + attr: "data-sod-kind-off", + }), + checkBox( + to("BaseStationShow"), + baseShown(d, type.type_id), + (v) => { + if (v) delete extra().기준측점; + else extra().기준측점 = false; + changed(); + }, + { edit, attr: "data-sod-base" }, + ), + ...(d.종류[type.type_id]?.동작 ?? []).map((a) => { + const tag = createTag(a, "neutral"); + tag.title = d.동작[a] ?? ""; + return tag; + }), + ], + }), + buildOptionsBlock({ def: d, type, edit, open, changed }), + buildLayoutBlock({ def: d, type, edit, changed }), + buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), + buildRulesBlock({ def: d, type, edit, changed }), + ); + }; + + const paintFoot = (): void => { + banner.replaceChildren( + ...(past + ? [ + el("span", { text: to("PastView", String(past.판)) }), + smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())), + ] + : [ + el("span", { text: to("Version", String(base.판)) }), + ...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]), + ]), + ); + banner.classList.toggle("is-past", !!past); + const list = past ? [] : problems(work); + issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t }))); + save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0; + }; + + const paint = (): void => { + paintKinds(); + paintDetail(); + paintFoot(); + }; + + const reload = async (): Promise => { + try { + base = await fetchStructureDef(); + work = clone(base); + dirty = false; + sync(); + } catch (again) { + showToast(to("Load_Failed", why(again)), "error"); + } + }; + + const save = createButton({ label: to("Save"), variant: "filled" }); + save.setAttribute("data-sod-save", ""); + save.addEventListener("click", async () => { + if (problems(work).length) return; + save.disabled = true; // 연달아 눌러도 한 번만 + try { + base = await saveStructureDef(base.판, tidy(work)); + work = clone(base); + dirty = false; + versions = null; + sync(); + showToast(to("SavedVersion", String(base.판)), "success"); + } catch (error) { + if (error instanceof StaleError) { + showToast(to("Stale"), "error"); + await reload(); + } else showToast(to("Failed", why(error)), "error"); + } finally { + paint(); + paintHistory(); + } + }); + + /** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */ + const paintHistory = (): void => { + if (!versions) return historyBox.replaceChildren(); + historyBox.replaceChildren( + ...(versions.length + ? versions.map((v) => { + const b = createButton({ + label: to("PastItem", `${v.판} · ${v.저장일}`), + variant: past?.판 === v.판 ? "filled" : "ghost", + size: "sm", + }); + b.setAttribute("data-sod-past", String(v.판)); + b.addEventListener("click", async () => { + try { + past = await fetchStructureDefAt(v.판); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + paint(); + paintHistory(); + }); + return b; + }) + : [el("p", { className: "m02-side__empty", text: to("PastNone") })]), + ); + }; + const history = createButton({ label: to("PastList"), variant: "ghost" }); + history.setAttribute("data-sod-history-toggle", ""); + history.addEventListener("click", async () => { + if (versions) { + versions = null; + return paintHistory(); + } + try { + versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + paintHistory(); + }); + + const node = el("div", { + className: "m02-opts m02-sod", + children: [ + el("p", { className: "m02-side__empty", text: to("DefNote") }), + banner, + el("div", { className: "m02-opts__panes", children: [kindList, detail] }), + issues, + el("div", { + className: "m02-side__row", + children: [...(opt.isAdmin ? [save] : []), history], + }), + historyBox, + ], + }); + const setDirty = opt.show(to("Screen"), node); + const sync = (): void => setDirty(dirty); + paint(); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts new file mode 100644 index 000000000..d6e4338e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * M02_SO_Def_Layout.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸) + * 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강). + * 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { + baseShown, + GRID_COLS, + live, + overlaps, + placeAll, + type Spot, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { head, numberBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +export interface LayoutBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + changed: () => void; +} + +const ROW_MAX = 60; + +/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */ +function preview(def: StructDef, type: TypeDef, cells: Map): HTMLElement { + const bad = new Set(overlaps(cells)); + const base = baseShown(def, type.type_id); + const offset = base ? 1 : 0; + const boxes: HTMLElement[] = []; + if (base) + boxes.push( + el("div", { + className: "m02-sod__cell is-base", + text: to("BaseStation"), + attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` }, + }), + ); + for (const o of live(type.options)) { + const c = cells.get(o.key)!; + boxes.push( + el("div", { + className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`, + text: o.label, + attrs: { + "data-sod-cell": o.key, + title: o.label, + style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`, + }, + }), + ); + } + return el("div", { + className: "m02-sod__grid", + attrs: { + "data-sod-preview": "", + style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`, + }, + children: boxes, + }); +} + +export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement { + const { def, type, edit, changed } = opt; + const cells = placeAll(type.options, def.배치[type.type_id]); + const rows = live(type.options).map((o) => { + const c = cells.get(o.key)!; + const fix = (patch: Partial): void => { + (def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch }; + changed(); + }; + return el("div", { + className: "m02-sod__place", + attrs: { "data-sod-place-row": o.key }, + children: [ + el("span", { className: "m02-sod__placename", text: o.label }), + el("small", { text: to("GridRow") }), + numberBox(c.줄, (n) => fix({ 줄: n }), { + edit, + min: 1, + max: ROW_MAX, + attr: "data-sod-row", + }), + el("small", { text: to("GridCol") }), + numberBox(c.칸, (n) => fix({ 칸: n }), { + edit, + min: 1, + max: GRID_COLS, + attr: "data-sod-col", + }), + el("small", { text: to("GridWidth") }), + numberBox(c.너비, (n) => fix({ 너비: n }), { + edit, + min: 1, + max: GRID_COLS, + attr: "data-sod-width", + }), + ], + }); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("LayoutHead"), to("LayoutNote")), + el("div", { + className: "m02-sod__layout", + children: [ + el("div", { className: "m02-sod__places", children: rows }), + preview(def, type, cells), + ], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts new file mode 100644 index 000000000..d45d9511b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -0,0 +1,268 @@ +/* ============================================================================= + * M02_SO_Def_Model.ts + * 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음. + * 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`). + * 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`). + * 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈. + * ========================================================================== */ + +export type OptionForm = "select" | "number" | "text" | "bool"; + +export interface Spot { + 줄: number; + 칸: number; + 너비: number; +} + +export interface OptionDef { + key: string; + label: string; + input: OptionForm; + choices?: string[]; + unit?: string | null; + default?: unknown; + enabled?: boolean; + phase?: "b05" | "detail"; + [more: string]: unknown; +} + +export interface TypeDef { + type_id: string; + group: string; + name: string; + options: OptionDef[]; + enabled?: boolean; + [more: string]: unknown; +} + +export type ShapeKey = "길이" | "높이" | "전" | "후"; +export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"]; + +export interface GroupDef { + id: string; + 이름: string; + 옵션: string[]; + 형상?: Partial>; +} + +/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */ +export interface RuleDef { + 종류: string; + 만약: { 옵션: string; 값들: string[] }; + 그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown }; +} + +export interface StructDef { + 판: number; + 구조물군: Record; + 동작: Record; + types: TypeDef[]; + 종류: Record; + 표시: Record>; + 그룹: Record; + 배치: Record>; + 규칙: RuleDef[]; + [more: string]: unknown; +} + +export const NAME_MAX = 40; +/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */ +export const GRID_COLS = 2; + +/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */ +export const clone = (value: T): T => structuredClone(value); + +/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */ +export function newKey(prefix: string, taken: Iterable): string { + const used = new Set(taken); + for (;;) { + const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`; + if (key.length > prefix.length + 3 && !used.has(key)) return key; + } +} + +/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */ +export function step(list: T[], index: number, delta: number): boolean { + const at = index + delta; + if (index < 0 || at < 0 || at >= list.length) return false; + [list[index], list[at]] = [list[at], list[index]]; + return true; +} + +/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */ +export const live = (list: readonly T[]): T[] => + list.filter((x) => x.enabled !== false); + +/** 기준 측점 칸 보이기 — 없으면 보임 */ +export const baseShown = (def: StructDef, typeId: string): boolean => + def.종류[typeId]?.기준측점 !== false; + +/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */ +export function badNames(names: readonly string[]): string[] { + const seen = new Set(); + const bad: string[] = []; + for (const raw of names) { + const name = raw.trim(); + if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)"); + seen.add(name); + } + return bad; +} + +/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */ +export function stepFamily(def: StructDef, key: string, delta: number): boolean { + const entries = Object.entries(def.구조물군); + if ( + !step( + entries, + entries.findIndex(([k]) => k === key), + delta, + ) + ) + return false; + def.구조물군 = Object.fromEntries(entries); + return true; +} + +/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */ +export function stepType(def: StructDef, typeId: string, delta: number): boolean { + const t = def.types.find((x) => x.type_id === typeId); + if (!t) return false; + const same = def.types.filter((x) => x.group === t.group); + const other = same[same.indexOf(t) + delta]; + if (!other) return false; + const i = def.types.indexOf(t); + const j = def.types.indexOf(other); + [def.types[i], def.types[j]] = [def.types[j], def.types[i]]; + return true; +} + +/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */ +export function renameChoice( + def: StructDef, + typeId: string, + o: OptionDef, + from: string, + to: string, +): void { + if (from === to || !o.choices) return; + o.choices = o.choices.map((c) => (c === from ? to : c)); + if (o.default === from) o.default = to; + for (const r of def.규칙) { + if (r.종류 !== typeId) continue; + if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v)); + if (r.그러면.옵션 === o.key) { + if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v)); + if (r.그러면.기본값 === from) r.그러면.기본값 = to; + } + } +} + +/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */ +export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void { + o.choices = (o.choices ?? []).filter((c) => c !== value); + if (o.default === value) o.default = null; + for (const r of def.규칙) { + if (r.종류 !== typeId) continue; + if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value); + if (r.그러면.옵션 === o.key) { + if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value); + if (r.그러면.기본값 === value) delete r.그러면.기본값; + } + } +} + +/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */ +export function placeAll( + options: readonly OptionDef[], + spots: Record = {}, + cols = GRID_COLS, +): Map { + const out = new Map(); + const taken = new Set(); + const mark = (c: Spot): void => { + for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`); + }; + const shown = live(options); + for (const o of shown) { + const s = spots[o.key]; + if (!s) continue; + const width = Math.max(1, Math.min(cols, s.너비 || 1)); + const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width }; + out.set(o.key, cell); + mark(cell); + } + let row = 1; + let col = 1; + for (const o of shown) { + if (out.has(o.key)) continue; + while (taken.has(`${row}:${col}`)) { + col += 1; + if (col > cols) ((row += 1), (col = 1)); + } + const cell = { 줄: row, 칸: col, 너비: 1 }; + out.set(o.key, cell); + mark(cell); + } + return out; +} + +/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */ +export function overlaps(cells: Map): string[] { + const owner = new Map(); + const bad = new Set(); + for (const [key, c] of cells) + for (let i = 0; i < c.너비; i += 1) { + const at = `${c.줄}:${c.칸 + i}`; + const other = owner.get(at); + if (other) bad.add(other).add(key); + else owner.set(at, key); + } + return [...bad]; +} + +/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */ +export function ruleBad(def: StructDef, r: RuleDef): boolean { + const t = def.types.find((x) => x.type_id === r.종류); + const src = t?.options.find((o) => o.key === r.만약.옵션); + const dst = t?.options.find((o) => o.key === r.그러면.옵션); + if (!src || !dst || src === dst || !r.만약.값들.length) return true; + if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true; + const { 선택지, 보이기, 기본값 } = r.그러면; + if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true; + let allowed = dst.choices ?? []; + if (선택지 !== undefined) { + if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true; + allowed = 선택지; + } + return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값)); +} + +/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */ +export function problems(def: StructDef): string[] { + const out: string[] = []; + const fam = badNames(Object.values(def.구조물군)); + if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`); + for (const g of Object.keys(def.구조물군)) { + const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name)); + if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`); + } + for (const t of def.types) { + const opt = badNames(t.options.map((o) => o.label)); + if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`); + for (const o of t.options) { + if (o.input !== "select") continue; + const ch = badNames(o.choices ?? []); + if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`); + if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); + } + const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); + if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); + const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); + if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); + } + def.규칙.forEach((r, i) => { + if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`); + }); + return out; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts new file mode 100644 index 000000000..10254bba8 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * M02_SO_Def_Options.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록** + * (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) + + * 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]). + * 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) · + * 저장은 화면 [저장](`M02_SO_Def.ts`). + * ========================================================================== */ + +import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { + dropChoice, + newKey, + renameChoice, + type OptionDef, + type OptionForm, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { buildPlaceSelect } from "./M02_SO_Groups"; +import { to } from "./M02_SO_Text"; + +export interface OptionsBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + /** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */ + open: Set; + changed: () => void; +} + +const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"]; +const FORM_TEXT = { + select: "FormSelect", + number: "FormNumber", + text: "FormText", + bool: "FormBool", +} as const; + +/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */ +function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement { + const attr = "data-sod-default"; + const now = o.default == null ? "" : String(o.default); + if (o.input === "select" || o.input === "bool") { + const pairs: [string, string][] = + o.input === "select" + ? (o.choices ?? []).map((c) => [c, c]) + : [ + ["true", to("Yes")], + ["false", to("No")], + ]; + return pickBox( + [["", to("NoDefault")], ...pairs], + now, + (v) => { + o.default = v === "" ? null : o.input === "bool" ? v === "true" : v; + changed(); + }, + { edit, attr, cls: "m02-sod__default" }, + ); + } + return textBox( + now, + (v) => { + const n = Number(v); + o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v; + }, + changed, + { edit, attr, cls: "m02-sod__default", place: to("NoDefault") }, + ); +} + +/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */ +function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement { + const { def, type, edit, changed } = opt; + const list = (o.choices ??= []); + const rows = list.map((c) => { + let next = c; + const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" }); + del.addEventListener("click", async () => { + if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return; + dropChoice(def, type.type_id, o, c); + changed(); + }); + return el("div", { + className: "m02-sod__choice", + attrs: { "data-sod-choice": c }, + children: [ + textBox( + c, + (v) => (next = v.trim()), + () => { + if (next && next !== c && !list.includes(next)) + renameChoice(def, type.type_id, o, c, next); + changed(); + }, + { edit, attr: "data-sod-choice-name" }, + ), + ...moveButtons(list, c, changed, edit), + ...(edit ? [del] : []), + ], + }); + }); + const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-choice-add", ""); + add.addEventListener("click", () => { + let n = list.length + 1; + while (list.includes(to("ChoiceNew", String(n)))) n += 1; + list.push(to("ChoiceNew", String(n))); + changed(); + }); + return el("div", { + className: "m02-sod__choices", + children: [ + el("p", { className: "m02-side__empty", text: to("ChoiceNote") }), + ...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]), + ...(edit ? [add] : []), + ], + }); +} + +/** 옵션 한 줄(+ 펼친 선택지) */ +function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement { + const { def, type, edit, open, changed } = opt; + const off = o.enabled === false; + const hidden = def.표시[type.type_id]?.[o.key] === false; + const form = pickBox( + FORMS.map((f) => [f, to(FORM_TEXT[f])] as const), + o.input, + (v) => { + o.input = v as OptionForm; + o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음 + if (o.input === "select") open.add(o.key); + changed(); + }, + { edit, attr: "data-sod-form", cls: "m02-sod__form" }, + ); + const choices = + o.input === "select" + ? createButton({ + label: to("Choices", String(o.choices?.length ?? 0)), + variant: open.has(o.key) ? "filled" : "ghost", + size: "sm", + }) + : null; + choices?.setAttribute("data-sod-choices", o.key); + choices?.addEventListener("click", () => { + if (open.has(o.key)) open.delete(o.key); + else open.add(o.key); + changed(); + }); + const show = checkBox( + to("Shown"), + !hidden, + (v) => { + const row = (def.표시[type.type_id] ??= {}); + if (v) delete row[o.key]; + else row[o.key] = false; + if (!Object.keys(row).length) delete def.표시[type.type_id]; + changed(); + }, + { edit, attr: "data-sod-shown" }, + ); + const row = el("div", { + className: `m02-sod__option${off ? " is-off" : ""}`, + attrs: { "data-sod-option": o.key }, + children: [ + textBox(o.label, (v) => (o.label = v), changed, { + edit, + attr: "data-sod-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: o.key }), + ...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []), + form, + textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, { + edit, + attr: "data-sod-unit", + cls: "m02-sod__unit", + place: to("Unit"), + max: 12, + }), + defaultBox(o, edit, changed), + show, + buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed), + checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), { + edit, + attr: "data-sod-off", + }), + ...moveButtons(type.options, o, changed, edit), + ...(choices ? [choices] : []), + ], + }); + return o.input === "select" && open.has(o.key) + ? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] }) + : row; +} + +/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */ +export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement { + const { type, edit, changed } = opt; + const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-option-add", ""); + add.addEventListener("click", () => { + const n = type.options.length + 1; + const o: OptionDef = { + key: newKey( + "opt_", + type.options.map((x) => x.key), + ), + label: to("OptionNew", String(n)), + input: "select", + choices: [to("ChoiceNew", "1")], + default: null, + }; + type.options.push(o); + opt.open.add(o.key); + changed(); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("OptionHead"), to("OptionNote")), + ...(type.options.length + ? type.options.map((o) => optionRow(opt, o)) + : [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]), + ...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts new file mode 100644 index 000000000..bca6186e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * M02_SO_Def_Rules.ts + * 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만) + * 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기. + * 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { + live, + ruleBad, + type OptionDef, + type RuleDef, + type StructDef, + type TypeDef, +} from "./M02_SO_Def_Model"; +import { head, pickBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +export interface RulesBlockOptions { + def: StructDef; + type: TypeDef; + edit: boolean; + changed: () => void; +} + +const bool = (): [string, string][] => [ + ["true", to("Yes")], + ["false", to("No")], +]; + +/** 여럿 고르기 — 체크한 것만 차례대로 */ +function multiPick( + all: readonly string[], + picked: readonly string[], + set: (v: string[]) => void, + edit: boolean, + attr: string, +): HTMLElement { + const on = new Set(picked); + return el("div", { + className: "m02-sod__rulechoices", + attrs: { [attr]: "" }, + children: all.map((c) => { + const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement; + box.checked = on.has(c); + box.disabled = !edit; + box.addEventListener("change", () => { + if (box.checked) on.add(c); + else on.delete(c); + set(all.filter((x) => on.has(x))); + }); + return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] }); + }), + }); +} + +/** 글 칸 — 쉼표로 여럿 */ +function listText( + value: string[], + set: (v: string[]) => void, + edit: boolean, + attr: string, +): HTMLElement { + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__rulepick", + attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") }, + }) as HTMLInputElement; + box.value = value.join(", "); + box.disabled = !edit; + box.addEventListener("change", () => + set( + box.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ), + ); + return box; +} + +/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */ +function whenValues( + src: OptionDef | undefined, + rule: RuleDef, + edit: boolean, + changed: () => void, +): HTMLElement { + const set = (v: string[]): void => ((rule.만약.값들 = v), changed()); + if (src?.input === "select") + return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value"); + if (src?.input === "bool") + return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), { + edit, + attr: "data-sod-rule-value", + cls: "m02-sod__rulepick", + }); + return listText(rule.만약.값들, set, edit, "data-sod-rule-value"); +} + +/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */ +function thenDefault( + dst: OptionDef | undefined, + rule: RuleDef, + edit: boolean, + changed: () => void, +): HTMLElement { + const then = rule.그러면; + const now = then.기본값 === undefined ? "" : String(then.기본값); + const set = (v: string): void => { + if (v === "") delete then.기본값; + else + then.기본값 = + dst?.input === "bool" + ? v === "true" + : dst?.input === "number" && Number.isFinite(Number(v)) + ? Number(v) + : v; + changed(); + }; + const pairs: [string, string][] = + dst?.input === "select" + ? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c]) + : dst?.input === "bool" + ? bool() + : []; + if (pairs.length || (dst?.input !== "number" && dst?.input !== "text")) + return pickBox([["", to("KeepDefault")], ...pairs], now, set, { + edit, + attr: "data-sod-rule-default", + cls: "m02-sod__rulepick", + }); + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__rulepick", + attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") }, + }) as HTMLInputElement; + box.value = now; + box.disabled = !edit; + box.addEventListener("change", () => set(box.value.trim())); + return box; +} + +function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement { + const { def, type, edit, changed } = opt; + const byKey = new Map(type.options.map((o) => [o.key, o])); + const names = live(type.options).map((o) => [o.key, o.label] as [string, string]); + const src = byKey.get(rule.만약.옵션); + const dst = byKey.get(rule.그러면.옵션); + const then = rule.그러면; + const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" }); + del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed())); + const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0"; + return el("div", { + className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`, + attrs: { "data-sod-rule": String(index) }, + children: [ + el("small", { text: to("RuleIf") }), + pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), { + edit, + attr: "data-sod-rule-top", + cls: "m02-sod__rulepick", + }), + el("small", { text: "=" }), + whenValues(src, rule, edit, changed), + el("small", { text: to("RuleThen") }), + pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), { + edit, + attr: "data-sod-rule-target", + cls: "m02-sod__rulepick", + }), + ...(dst?.input === "select" + ? [ + el("small", { text: to("RuleChoices") }), + multiPick( + dst.choices ?? [], + then.선택지 ?? [], + (v) => { + if (v.length) then.선택지 = v; + else delete then.선택지; + changed(); + }, + edit, + "data-sod-rule-choice", + ), + ] + : []), + pickBox( + [ + ["", to("Keep")], + ["1", to("Show")], + ["0", to("Hide")], + ], + show, + (v) => { + if (v === "") delete then.보이기; + else then.보이기 = v === "1"; + changed(); + }, + { edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" }, + ), + thenDefault(dst, rule, edit, changed), + ...(edit ? [del] : []), + ], + }); +} + +export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement { + const { def, type, edit, changed } = opt; + const mine = def.규칙.filter((r) => r.종류 === type.type_id); + const opts = live(type.options); + const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" }); + add.setAttribute("data-sod-rule-add", ""); + add.disabled = opts.length < 2; + add.addEventListener("click", () => { + const top = opts.find((o) => o.input === "select") ?? opts[0]; + const target = opts.find((o) => o.key !== top.key)!; + def.규칙.push({ + 종류: type.type_id, + 만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] }, + 그러면: { 옵션: target.key, 보이기: false }, + }); + changed(); + }); + return el("div", { + className: "m02-sod__block", + children: [ + ...head(to("RuleHead"), to("RuleNote")), + ...(mine.length + ? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r))) + : [el("p", { className: "m02-side__empty", text: to("RuleNone") })]), + ...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts new file mode 100644 index 000000000..837c344bd --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * M02_SO_Def_Ui.ts + * 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추. + * 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { step } from "./M02_SO_Def_Model"; +import { to } from "./M02_SO_Text"; + +/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */ +export function textBox( + value: string, + set: (v: string) => void, + done: () => void, + opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number }, +): HTMLInputElement { + const box = el("input", { + className: `ui-input ui-input--sm ${opt.cls ?? ""}`, + attrs: { + type: "text", + maxlength: String(opt.max ?? 40), + ...(opt.attr ? { [opt.attr]: "" } : {}), + }, + }) as HTMLInputElement; + box.value = value; + box.placeholder = opt.place ?? ""; + box.disabled = !opt.edit; + box.addEventListener("input", () => { + set(box.value); + box.classList.toggle("is-invalid", !box.value.trim() && !opt.place); + }); + box.addEventListener("change", done); + return box; +} + +/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */ +export function numberBox( + value: number, + set: (v: number) => void, + opt: { edit: boolean; min: number; max: number; attr?: string }, +): HTMLInputElement { + const box = el("input", { + className: "ui-input ui-input--sm m02-sod__num", + attrs: { + type: "number", + min: String(opt.min), + max: String(opt.max), + ...(opt.attr ? { [opt.attr]: "" } : {}), + }, + }) as HTMLInputElement; + box.value = String(value); + box.disabled = !opt.edit; + box.addEventListener("change", () => { + const n = Math.round(Number(box.value)); + if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) { + box.value = String(value); + return; + } + set(n); + }); + return box; +} + +/** 체크 + 글 */ +export function checkBox( + label: string, + on: boolean, + set: (v: boolean) => void, + opt: { edit: boolean; attr?: string }, +): HTMLLabelElement { + const box = el("input", { + attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) }, + }) as HTMLInputElement; + box.checked = on; + box.disabled = !opt.edit; + box.addEventListener("change", () => set(box.checked)); + return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] }); +} + +/** 고르기 — [값, 글] 짝 */ +export function pickBox( + pairs: readonly (readonly [string, string])[], + value: string, + set: (v: string) => void, + opt: { edit: boolean; attr?: string; cls?: string }, +): HTMLSelectElement { + const box = el("select", { + className: `ui-input ui-input--sm ${opt.cls ?? ""}`, + attrs: opt.attr ? { [opt.attr]: "" } : {}, + }) as HTMLSelectElement; + box.append(...pairs.map(([v, t]) => new Option(t, v))); + box.value = value; + box.disabled = !opt.edit; + box.addEventListener("change", () => set(box.value)); + return box; +} + +/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */ +export function moveButtons( + list: T[], + item: T, + changed: () => void, + edit: boolean, +): HTMLElement[] { + if (!edit) return []; + const index = list.indexOf(item); + const make = (label: string, delta: number, off: boolean): HTMLButtonElement => { + const b = createButton({ label, variant: "ghost", size: "sm" }); + b.disabled = off; + b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed()); + return b; + }; + return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)]; +} + +/** 절 제목 + 안내 한 줄 */ +export const head = (title: string, note?: string): HTMLElement[] => [ + el("h4", { className: "m02-opts__group", text: title }), + ...(note ? [el("p", { className: "m02-side__empty", text: note })] : []), +]; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 640d6942a..0ba02af12 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -7,7 +7,9 @@ from __future__ import annotations +import json from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import Any @@ -32,6 +34,26 @@ def history_path(version: int) -> Path: return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json" +def history() -> list[dict[str, Any]]: + """지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지).""" + folder = history_path(1).parent + rows = [] + for path in folder.glob("판_*.json") if folder.is_dir() else []: + number = path.stem.removeprefix("판_") + if number.isdigit(): + when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + rows.append({"판": int(number), "저장일": when}) + return sorted(rows, key=lambda r: r["판"], reverse=True) + + +def read_version(version: int) -> dict[str, Any]: + """지난 판 한 벌(읽기만) — 없으면 404.""" + path = history_path(version) + if not path.is_file(): + raise store.StoreError(404, f"판 {version} 이력이 없음") + return json.loads(path.read_text("utf-8")) + + def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]: """(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음.""" errors: list[str] = [] diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts index 242dad8b4..410875cf4 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts @@ -1,19 +1,21 @@ /* ============================================================================= * M02_SO_Groups.ts - * 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록 - * (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹). - * 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`). + * 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록 + * (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) · + * 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹). + * 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`). * 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`). * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import type { OptionGroupDef } from "./M02_SO_Api"; +import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model"; +import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { to } from "./M02_SO_Text"; const NAME_MAX = 40; /** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */ -export function newGroupId(list: readonly OptionGroupDef[]): string { +export function newGroupId(list: readonly GroupDef[]): string { const taken = new Set(list.map((g) => g.id)); for (;;) { const id = `g${Math.random().toString(36).slice(2, 8)}`; @@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string { } /** 그 옵션이 든 그룹 id — 없으면 null(본체) */ -export function groupOf(list: readonly OptionGroupDef[], key: string): string | null { +export function groupOf(list: readonly GroupDef[], key: string): string | null { return list.find((g) => g.옵션.includes(key))?.id ?? null; } /** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */ -export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void { +export function moveOption(list: GroupDef[], key: string, gid: string | null): void { for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key); const target = gid ? list.find((g) => g.id === gid) : null; if (target) target.옵션.push(key); } -/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */ -export function namesOk(groups: Map): boolean { - for (const list of groups.values()) { - const names = list.map((g) => g.이름.trim()); - if (names.some((n) => !n || n.length > NAME_MAX)) return false; - if (new Set(names).size !== names.length) return false; - } - return true; -} - export interface GroupBlockOptions { /** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */ - list: OptionGroupDef[]; + list: GroupDef[]; edit: boolean; /** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */ changed: () => void; } +/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */ +function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement { + const shape = (group.형상 ??= {}); + return el("div", { + className: "m02-sog__shape", + attrs: { "data-sog-shape": group.id }, + children: SHAPE_KEYS.map((k) => { + const f = shape[k] ?? { 받기: false, 뜻: "" }; + const on = checkBox( + to(`Shape_${k}` as const), + f.받기, + (v) => ((shape[k] = { ...f, 받기: v }), changed()), + { edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` }, + ); + const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, { + edit: edit && f.받기, + cls: "m02-sog__mean", + place: to("ShapeMean"), + }); + return el("div", { className: "m02-sog__shapecell", children: [on, mean] }); + }), + }); +} + /** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { const { list } = opt; @@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { list.splice(list.indexOf(group), 1); opt.changed(); }); - return el("div", { + const line = el("div", { className: "m02-sog__row", attrs: { "data-sog-group": group.id }, children: [ @@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { ...(opt.edit ? [up, down, del] : []), ], }); + return el("div", { + className: "m02-sog__item", + children: [line, shapeLine(group, opt.edit, opt.changed)], + }); }); const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" }); add.setAttribute("data-sog-add", ""); @@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement { /** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */ export function buildPlaceSelect( - list: OptionGroupDef[], + list: GroupDef[], key: string, edit: boolean, changed: () => void, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index 6fac4334a..7ede2af54 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -1,25 +1,12 @@ /* ============================================================================= * M02_SO_Options.ts - * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에 - * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 - * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. - * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). - * 서버 = `M02_SO_Options.py`(정의 「표시」 · 숨긴 것만) · `M02_SO_Groups.py`(정의 「그룹」). + * 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에 + * 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만. + * 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감. * ========================================================================== */ -import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; -import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; -import { - fetchStructureOptionGroups, - fetchStructureOptions, - saveStructureOptionGroups, - saveStructureOptions, - type GroupsDoc, - type OptionGroupDef, - type OptionKind, - type OptionsDoc, -} from "./M02_SO_Api"; -import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups"; +import { el } from "@ui/ui_template_elements"; +import { openDefScreen } from "./M02_SO_Def"; import "./M02_SO_Style.css"; import { to } from "./M02_SO_Text"; @@ -40,8 +27,6 @@ export interface OptionsSection { clear: () => void; } -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); - export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection { const row = el("button", { className: "m02-side__item ui-sidebar-row", @@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection attrs: { type: "button", "data-options-row": "" }, }); const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); - row.addEventListener("click", () => void open()); /** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */ let opened = false; @@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection opened = on; row.classList.toggle("is-active", on); }; - - async function open(): Promise { + row.addEventListener("click", async () => { if (opened || !(await opt.confirmLeave())) return; - let doc: OptionsDoc; - let groups: GroupsDoc; - try { - [doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]); - } catch (error) { - return showToast(to("Load_Failed", why(error)), "error"); - } - draw(doc, groups); - } - - /** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ - function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void { - let version = doc.판; - let groupVersion = groupsDoc.판; - const edit = opt.isAdmin; - /** 숨긴 옵션 — 종류 → 키 집합 */ - const hidden = new Map>(); - const load = (src: OptionsDoc["표시"]): void => { - hidden.clear(); - for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys))); - }; - /** 그룹 — 종류 → 그룹[](고칠 사본) */ - const groups = new Map(); - const loadGroups = (src: GroupsDoc["그룹"]): void => { - groups.clear(); - for (const [id, list] of Object.entries(src)) - groups.set( - id, - list.map((g) => ({ ...g, 옵션: [...g.옵션] })), - ); - }; - load(doc.표시); - loadGroups(groupsDoc.그룹); - /** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */ - let kinds = groupsDoc.종류; - let current: OptionKind | null = null; - let shownDirty = false; - let groupDirty = false; - - const kindList = el("div", { className: "m02-opts__kinds" }); - const optionList = el("div", { className: "m02-opts__list" }); - const off = (id: string): number => hidden.get(id)?.size ?? 0; - const groupList = (id: string): OptionGroupDef[] => { - if (!groups.has(id)) groups.set(id, []); - return groups.get(id)!; - }; - - const paintKinds = (): void => { - const parts: HTMLElement[] = []; - let group = ""; - for (const k of kinds) { - if (k.group !== group) { - group = k.group; - parts.push( - el("h4", { - className: "m02-opts__group", - text: group.length === 1 ? to("Group", group) : group, - }), - ); - } - const n = off(k.type_id); - const g = groups.get(k.type_id)?.length ?? 0; - const b = el("button", { - className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`, - attrs: { type: "button", "data-options-kind": k.type_id }, - children: [ - el("span", { text: k.name }), - ...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []), - ...(n ? [createTag(to("Hidden", String(n)), "warning")] : []), - ...(k.enabled ? [] : [createTag(to("Off"), "neutral")]), - ], - }); - b.addEventListener("click", () => { - current = k; - paintKinds(); - paintOptions(); - }); - parts.push(b); - } - kindList.replaceChildren(...parts); - }; - - /** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */ - const groupsChanged = (): void => { - groupDirty = true; - sync(); - paintKinds(); - paintOptions(); - }; - - const paintOptions = (): void => { - if (!current) { - optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); - return; - } - const kind = current; - const list = groupList(kind.type_id); - const rows = kind.options.map((o) => { - const box = el("input", { - attrs: { type: "checkbox", "data-options-key": o.key }, - }) as HTMLInputElement; - box.checked = !hidden.get(kind.type_id)?.has(o.key); - box.disabled = !edit; - box.addEventListener("change", () => { - const set = hidden.get(kind.type_id) ?? new Set(); - if (box.checked) set.delete(o.key); - else set.add(o.key); - hidden.set(kind.type_id, set); - shownDirty = true; - sync(); - paintKinds(); - }); - const tags: HTMLElement[] = []; - if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent")); - if (o.required && o.phase === "b05") { - const tag = createTag(to("Required"), "danger"); - tag.title = to("RequiredTip"); - tags.push(tag); - } - return el("div", { - className: "m02-opts__option", - children: [ - el("label", { - className: "m02-opts__check", - children: [ - box, - el("span", { text: o.label }), - el("small", { className: "m02-opts__key", text: o.key }), - ...tags, - ], - }), - buildPlaceSelect(list, o.key, edit, groupsChanged), - ], - }); - }); - optionList.replaceChildren( - el("h3", { className: "m02-options__head", text: kind.name }), - buildGroupBlock({ list, edit, changed: groupsChanged }), - el("h4", { className: "m02-opts__group", text: to("OptionHead") }), - ...(rows.length - ? rows - : [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]), - ); - }; - - const save = createButton({ label: to("Save"), variant: "filled" }); - const all = createButton({ label: to("ShowAll"), variant: "ghost" }); - all.addEventListener("click", () => { - if (![...hidden.values()].some((s) => s.size)) return; - hidden.clear(); - shownDirty = true; - sync(); - paintKinds(); - paintOptions(); - }); - - /** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */ - const reload = async (): Promise => { - try { - const [fresh, freshGroups] = await Promise.all([ - fetchStructureOptions(), - fetchStructureOptionGroups(), - ]); - version = fresh.판; - groupVersion = freshGroups.판; - kinds = freshGroups.종류; - load(fresh.표시); - loadGroups(freshGroups.그룹); - shownDirty = groupDirty = false; - sync(); - current = kinds.find((k) => k.type_id === current?.type_id) ?? null; - paintKinds(); - paintOptions(); - } catch (again) { - showToast(to("Load_Failed", why(again)), "error"); - } - }; - - save.addEventListener("click", async () => { - if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error"); - save.disabled = true; // 연달아 눌러도 한 번만 - try { - if (shownDirty) { - const 표시: OptionsDoc["표시"] = {}; - for (const [id, keys] of hidden) - if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); - const done = await saveStructureOptions(version, 표시); - version = done.판; - load(done.표시); - shownDirty = false; - } - if (groupDirty) { - const 그룹: GroupsDoc["그룹"] = {}; - for (const [id, list] of groups) - if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() })); - const done = await saveStructureOptionGroups(groupVersion, 그룹); - groupVersion = done.판; - loadGroups(done.그룹); - groupDirty = false; - } - sync(); - showToast(to("Saved"), "success"); - paintKinds(); - paintOptions(); - } catch (error) { - if (error instanceof StaleError) { - showToast(to("Stale"), "error"); - await reload(); - } else showToast(to("Failed", why(error)), "error"); - } finally { - save.disabled = false; - } - }); - - const node = el("div", { - className: "m02-opts", - children: [ - el("p", { className: "m02-side__empty", text: to("Note") }), - el("div", { - className: "m02-opts__panes", - children: [kindList, optionList], - }), - ...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []), - ], - }); - const setDirty = opt.show(to("Screen"), node); - const sync = (): void => setDirty(shownDirty || groupDirty); - paintKinds(); - paintOptions(); + if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return; opt.onOpen(); mark(true); - } + }); return { title: to("Title"), body, clear: () => mark(false) }; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index 0dbfc8a22..b18945fdb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -84,6 +84,20 @@ def get_structure_definition(session=Depends(verify_session)) -> dict: return _call(definition.read) +@read_router.get("/structure-definition/history") +def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]: + """지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2).""" + access.require(session, "system", None) + return _call(definition.history) + + +@read_router.get("/structure-definition/history/{version}") +def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict: + """지난 판 한 벌(읽기만) — 없으면 404.""" + access.require(session, "system", None) + return _call(definition.read_version, version) + + @read_router.put("/structure-definition") def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict: """정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력.""" diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 2670d6cfe..a8570c86a 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -112,3 +112,223 @@ color: var(--color-text-muted); font-size: 11px; } + +/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */ +.m02-sod { + max-width: 80rem; +} + +.m02-sod__banner { + display: flex; + align-items: center; + gap: var(--spacing-8); + color: var(--color-text-muted); +} + +.m02-sod__banner.is-past { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + background: var(--color-mist-violet); + color: var(--color-text); +} + +.m02-sod__family, +.m02-sod__kindrow, +.m02-sod__kindhead, +.m02-sod__option, +.m02-sod__choice, +.m02-sod__place, +.m02-sod__rule { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sod__family { + margin-top: var(--spacing-8); +} + +.m02-sod__family > .ui-input { + flex: 1 1 8rem; + min-width: 0; + font-weight: 600; +} + +.m02-sod__kindrow { + flex-wrap: nowrap; +} + +.m02-sod__kindrow > .m02-opts__kind { + flex: 1 1 auto; + min-width: 0; +} + +.m02-sod__kindrow > button:not(.m02-opts__kind), +.m02-sod__family > button { + flex: none; + white-space: nowrap; +} + +.m02-sod__block { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-sod__option { + padding: var(--spacing-4) 0; + border-bottom: 1px solid var(--color-border); +} + +.is-off > .ui-input, +.m02-opts__kind.is-off > span:first-child { + color: var(--color-text-muted); + text-decoration: line-through; +} + +.m02-sod__name { + flex: 1 1 10rem; + min-width: 0; +} + +.m02-sod__form, +.m02-sod__default { + width: 8rem; +} + +.m02-sod__unit, +.m02-sod__num { + width: 4.5rem; +} + +.m02-sod__check { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + white-space: nowrap; + cursor: pointer; +} + +.m02-sod__choices { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0 0 var(--spacing-8) var(--spacing-24); + padding-left: var(--spacing-8); + border-left: 2px solid var(--color-border); +} + +/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */ +.m02-sod__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem); + gap: var(--spacing-16); + align-items: start; +} + +.m02-sod__place { + flex-wrap: nowrap; +} + +.m02-sod__placename { + flex: 1 1 6rem; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.m02-sod__place > .m02-sod__num { + flex: none; + width: 4rem; +} + +.m02-sod__grid { + display: grid; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-sod__cell { + overflow: hidden; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.m02-sod__cell.is-base { + background: var(--color-mist-violet); +} + +.m02-sod__cell.is-bad, +.m02-sod__rule.is-bad { + border-color: var(--color-danger); +} + +.m02-sod__rule { + padding: var(--spacing-4); + border: 1px solid transparent; + border-radius: var(--radius-sm); +} + +.m02-sod__rulepick { + width: 9rem; +} + +.m02-sod__rulechoices { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */ +.m02-sog__item { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.m02-sog__shape { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--spacing-4) var(--spacing-12); +} + +.m02-sog__shapecell { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.m02-sog__mean { + flex: 1 1 auto; + min-width: 0; +} + +.m02-sod__issues:empty, +.m02-sod__history:empty { + display: none; +} + +.m02-sod__history { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.m02-sod__bad { + margin: 0; + color: var(--color-danger); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 9b4409b41..90037edd8 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -6,13 +6,12 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { - Title: ["구조물 옵션 보이기", "Structure options shown"], - Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"], - Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"], - Note: [ - "체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임", - "Only checked options appear in the B05 · B06 forms — definitions and stored values stay", + Title: ["구조물 옵션 정의", "Structure option definition"], + Row: [ + "구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙", + "Families · types · options · layout · groups · rules", ], + Screen: ["구조물 옵션 정의", "Structure option definition"], GroupHead: ["그룹", "Groups"], GroupNote: [ "그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체", @@ -35,35 +34,92 @@ const TEXT = { ], GroupTag: ["그룹 {value}", "{value} groups"], Main: ["본체", "Main body"], - OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"], + OptionHead: ["옵션", "Options"], + OptionNote: [ + "이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침", + "Name · form · unit · default · container · unused · order — keys never change", + ], + OptionAdd: ["옵션 더함", "Add option"], + OptionNew: ["새 옵션 {value}", "New option {value}"], + FormSelect: ["고르기", "Select"], + FormNumber: ["숫자", "Number"], + FormText: ["글", "Text"], + FormBool: ["예/아니오", "Yes/No"], + Yes: ["예", "Yes"], + No: ["아니오", "No"], + Unit: ["단위", "Unit"], + NoDefault: ["기본값 없음", "No default"], + Unused: ["안 씀", "Unused"], + Choices: ["선택지 {value}", "{value} choices"], + ChoiceAdd: ["선택지 더함", "Add choice"], + ChoiceNew: ["새 선택지 {value}", "New choice {value}"], + ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"], + ChoiceNote: [ + "선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음", + "Choice text is the stored value — renaming updates defaults and rules here", + ], + ChoiceDrop: ["빼기", "Remove"], + ChoiceDropAsk: [ + "선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음", + "Remove choice “{value}” — also from defaults and rules", + ], + Shown: ["보이기", "Shown"], + RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"], + RuleChoices: ["선택지", "Choices"], + LayoutHead: ["배치 격자", "Layout grid"], + LayoutNote: [ + "B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로", + "Left panel layout (two columns) — row · column · width per option · overlaps in red", + ], + GridRow: ["줄", "Row"], + GridCol: ["칸", "Col"], + GridWidth: ["너비", "Width"], + BaseStation: ["기준 측점", "Base station"], + BaseStationShow: ["기준 측점 표시", "Show base station"], + Shape_길이: ["길이", "Length"], + Shape_높이: ["높이", "Height"], + Shape_전: ["기준 측점 전", "Before"], + Shape_후: ["기준 측점 후", "After"], + ShapeMean: ["무엇을 정하는지", "What it controls"], + RuleHead: ["규칙", "Rules"], + RuleNote: [ + "만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강", + "If parent = value → child choices · visibility · default", + ], + RuleNone: ["규칙 없음", "No rules"], + RuleAdd: ["규칙 더함", "Add rule"], + RuleIf: ["만약", "If"], + RuleThen: ["→", "→"], + Keep: ["보이기 그대로", "Keep visibility"], + Show: ["보임", "Show"], + Hide: ["숨김", "Hide"], + KeepDefault: ["기본값 그대로", "Keep default"], + DefNote: [ + "구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음", + "Structure definition — Save bumps the version; past versions stay in history", + ], + Version: ["판 {value}", "Version {value}"], + ReadOnly: ["읽기만", "Read only"], + SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"], + PastList: ["지난 판", "Past versions"], + PastItem: ["판 {value}", "v{value}"], + PastNone: ["지난 판 없음", "No past versions"], + PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"], + PastBack: ["지금 판으로", "Back to current"], NoOptionKind: [ "이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)", "No options — groups only (each links a combo)", ], - NameBad: [ - "그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨", - "Group names: 1–40 chars, unique", - ], - Group: ["{value}군", "Group {value}"], - Hidden: ["숨김 {value}", "{value} hidden"], Off: ["안 쓰는 종류", "Not offered"], - NoOption: ["옵션 없음", "No options"], Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"], Detail: ["B06 전용", "B06 only"], - Required: ["필수", "Required"], - RequiredTip: [ - "필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)", - "Required — when hidden it is not shown and not enforced", - ], Save: ["저장", "Save"], - ShowAll: ["모두 보임", "Show all"], - Saved: ["저장함", "Saved"], Stale: [ - "그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요", + "그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요", "Changed meanwhile — reloaded, please edit again", ], - Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"], - Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"], + Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"], + Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"], } as const satisfies Record; export type OptionsTextKey = keyof typeof TEXT; diff --git a/resources/tester/helper_m02_so_def_model.cjs b/resources/tester/helper_m02_so_def_model.cjs new file mode 100644 index 000000000..92969f79c --- /dev/null +++ b/resources/tester/helper_m02_so_def_model.cjs @@ -0,0 +1,112 @@ +/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const source = fs.readFileSync( + path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"), + "utf8", +); +const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] }); +const type = () => ({ + type_id: "k1", + group: "A", + name: "배수관", + options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }], +}); +const defOf = (over = {}) => ({ + 판: 3, + 구조물군: { A: "A군", B: "B군" }, + 동작: {}, + types: [ + type(), + { ...type(), type_id: "k2", name: "둘째" }, + { ...type(), type_id: "k3", group: "B", name: "셋째" }, + ], + 종류: {}, + 표시: {}, + 그룹: {}, + 배치: {}, + 규칙: [], + ...over, +}); +const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots)); + +const out = {}; +// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄 +out.auto = cells(type()); +// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음 +out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } }); +// 안 씀은 격자에서 빠짐 +const off = type(); +off.options[0].enabled = false; +out.off = cells(off); +// 겹침 +out.overlap = m + .overlaps( + m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }), + ) + .sort(); +// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위 +const rd = defOf(); +const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 }); +out.rules = [ + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }), + R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }), + R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }), + R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }), +].map((r) => m.ruleBad(rd, r)); +// 이름 — 빈 이름 · 겹침 +out.names = m.badNames(["가", " ", "가"]); +// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭 +out.clean = m.problems(defOf()); +const broken = defOf({ + 배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } }, +}); +broken.types[0].options[1].label = "a"; +broken.types[1].options[0].choices = []; +out.broken = m.problems(broken); +// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이 +const rn = defOf({ + 규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })], +}); +const a = rn.types[0].options[0]; +const b = rn.types[0].options[1]; +a.default = "x"; +m.renameChoice(rn, "k1", a, "x", "엑스"); +m.renameChoice(rn, "k1", b, "p", "피"); +out.rename = [ + a.choices, + a.default, + rn.규칙[0].만약.값들, + rn.규칙[0].그러면.선택지, + rn.규칙[0].그러면.기본값, +]; +m.dropChoice(rn, "k1", b, "피"); +out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면]; +// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만 +const od = defOf(); +out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)]; +out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)]; +out.base = [ + m.baseShown(defOf(), "k1"), + m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"), +]; +// 목록 순서 바꿈 · 끝은 그대로 +const list = [1, 2, 3]; +out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list]; +// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자 +const k = m.newKey("opt_", ["opt_aaaaaa"]); +out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_so_def_model.py b/resources/tester/test_m02_so_def_model.py new file mode 100644 index 000000000..6670f0937 --- /dev/null +++ b/resources/tester/test_m02_so_def_model.py @@ -0,0 +1,87 @@ +"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증. + +프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음. + - 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄 + (서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함. + - 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글. + - 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침. + - 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_model.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배치_자동_채움(r) -> None: + assert r["auto"] == { + "a": {"줄": 1, "칸": 1, "너비": 1}, + "b": {"줄": 1, "칸": 2, "너비": 1}, + "c": {"줄": 2, "칸": 1, "너비": 1}, + } + + +def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None: + assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2} + assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1} + assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1} + + +def test_안씀은_격자에서_빠짐(r) -> None: + assert set(r["off"]) == {"b", "c"} + assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1} + + +def test_겹침(r) -> None: + assert r["overlap"] == ["a", "b"] + + +def test_규칙_틀린_줄(r) -> None: + assert r["rules"] == [False, True, True, True, True, False, True] + + +def test_이름_검사(r) -> None: + assert r["names"] == ["(빈 이름)", "가"] + + +def test_저장_전_검사(r) -> None: + assert r["clean"] == [] + assert any("옵션 이름" in t for t in r["broken"]) + assert any("배치 겹침" in t for t in r["broken"]) + assert any("선택지가 있어야" in t for t in r["broken"]) + + +def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None: + assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"] + assert r["drop"] == [["q"], [], False] + + +def test_순서_기준측점_새키(r) -> None: + assert r["family"] == [False, True, ["B", "A"]] + assert r["type"] == [False, True, ["k2", "k1", "k3"]] + assert r["base"] == [True, False] + assert r["step"] == [False, True, [2, 1, 3]] + assert r["newKey"] == [True, True] diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py new file mode 100644 index 000000000..c344372c5 --- /dev/null +++ b/resources/tester/test_m02_structure_def_screen.py @@ -0,0 +1,123 @@ +"""M02 구조물 정의 편집 화면(PLAN 48-2) — 지난 판 길 · 화면이 보내는 꼴을 서버가 받음. + +못박는 것 + - 지난 판 목록 `[{판, 저장일}]` 새 판부터 · 한 벌 읽기 · 없는 판 404 · 읽기는 누구나. + - 화면이 고친 정의(이름 · 순서 · 안 씀 · 기준측점 · 선택지 이름 · 배치 · 그룹 형상 · 규칙)를 + 그대로 PUT 하면 판 +1 · 같은 값으로 다시 읽힘. + - 시스템 관리자 아니면 PUT 403(화면은 읽기만). + - 마스터 정본은 안 씀(사본). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile import B05_Profile_Structures_Schema as schema +from common_util.common_util_auth import verify_session +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + +URL = "/api/m02/structure-definition" +ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} +USER = {"role": "USER", "user_id": 2, "company_id": 1} + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _client(session: dict) -> TestClient: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +def _screen_edit(doc: dict) -> dict: + """화면이 하는 고침 한 벌 — `M02_SO_Def*.ts` 가 만드는 꼴 그대로.""" + pipe = next(t for t in doc["types"] if t["type_id"] == "pipe") + pipe["name"] = "배수관(고침)" + kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind") + kind["label"] = "관 종류" + kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]] + pipe["options"].append( + { + "key": "opt_ab12cd", + "label": "새 옵션 1", + "input": "select", + "choices": ["새 선택지 1", "새 선택지 2"], + "default": None, + } + ) + pipe["options"][1]["enabled"] = False # 안 씀 + first = list(doc["구조물군"]) + doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]} + doc["종류"].setdefault("pipe", {})["기준측점"] = False + doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}} + doc["표시"]["pipe"] = {"inlet_type": False} + doc["그룹"]["pipe"][1]["형상"] = { + "길이": {"받기": True, "뜻": "유입 보호공 연장"}, + "높이": {"받기": False, "뜻": ""}, + } + doc["규칙"] = [ + { + "종류": "pipe", + "만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]}, + "그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"}, + }, + { + "종류": "pipe", + "만약": {"옵션": "inlet_type", "값들": ["집수정"]}, + "그러면": {"옵션": "opt_ab12cd", "보이기": False}, + }, + ] + return doc + + +def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + assert admin.get(f"{URL}/history").json() == [] + sent = _screen_edit(json.loads(json.dumps(doc))) + res = admin.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 200, res.text + got = res.json() + assert got["판"] == doc["판"] + 1 + for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"): + assert got[key] == sent[key], key + assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로 + rows = admin.get(f"{URL}/history").json() + assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16 + old = admin.get(f"{URL}/history/{doc['판']}").json() + assert old == doc + assert admin.get(f"{URL}/history/999").status_code == 404 + + +def test_지난_판은_누구나_읽고_쓰기는_시스템_관리자만(copy) -> None: + user = _client(USER) + doc = user.get(URL).json() + assert user.get(f"{URL}/history").status_code == 200 + assert user.put(URL, json={"판": doc["판"], "정의": _screen_edit(doc)}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"] + + +def test_화면이_못_거른_틀린_규칙은_422_안_씀(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + doc["규칙"] = [ + { + "종류": "pipe", + "만약": {"옵션": "pipe_kind", "값들": ["없음"]}, + "그러면": {"옵션": "inlet_type"}, + } + ] + assert admin.put(URL, json={"판": doc["판"], "정의": doc}).status_code == 422 + assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"] diff --git a/resources/tester/test_m02_structure_option_groups.py b/resources/tester/test_m02_structure_option_groups.py index 7e3ca8312..144ac9314 100644 --- a/resources/tester/test_m02_structure_option_groups.py +++ b/resources/tester/test_m02_structure_option_groups.py @@ -102,8 +102,10 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None: assert res.status_code == 200 and res.json()["그룹"] == {} -def test_좌측_옵션_보이기는_시스템_관리자만() -> None: +def test_좌측_정의_줄은_시스템_층에_늘_고쳐_씀은_시스템_관리자만() -> None: + """PLAN 48-2 — 다른 역할은 읽기만(화면이 칸을 잠금 · 서버 PUT 403).""" side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") - assert "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side - ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8") - assert "buildGroupBlock(" in ui and "buildPlaceSelect(" in ui + assert "옵션: section(options.title, options.body)," in side + folder = ROOT / "M02_MasterTemplete/StructOptions" + assert "buildGroupBlock(" in (folder / "M02_SO_Def.ts").read_text("utf-8") + assert "buildPlaceSelect(" in (folder / "M02_SO_Def_Options.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 971a94575..0bf9bd334 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -123,12 +123,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert "matset" not in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 + # 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄 api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8") - assert ( - 'call("/layers/system/structure-options")' in api - and 'call("/structure-options", { method: "PUT"' in api - ) - ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8") + assert 'call("/structure-definition")' in api + ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts").read_text("utf-8") assert 'o.phase === "detail"' in ui # B06 전용 표시 diff --git a/resources/tester/ui_checks/48-1_구조물정의_읽기.js b/resources/tester/ui_checks/48-1_구조물정의_읽기.js index 306517a2f..b66fdfe35 100644 --- a/resources/tester/ui_checks/48-1_구조물정의_읽기.js +++ b/resources/tester/ui_checks/48-1_구조물정의_읽기.js @@ -5,7 +5,7 @@ * - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition) * - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] } * (GET /api/projects/structure-types — 옛 꼴 그대로) - * - 좌측 「구조물 옵션 보이기」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 옛 화면 그대로) + * - 좌측 「구조물 옵션 정의」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 48-2 정의 화면) * · pipe 단추에 그룹 표지 「3」 */ (async () => { @@ -41,14 +41,14 @@ pipeGroups: pipe.groups.map((g) => g.name), }; const row = await until(() => $("[data-options-row]")); - if (!row) return JSON.stringify({ ...out, error: "옵션 보이기 줄 없음" }); + if (!row) return JSON.stringify({ ...out, error: "정의 줄 없음" }); row.click(); - await until(() => $$("[data-options-kind]").length); - const kinds = $$("[data-options-kind]"); + await until(() => $$("[data-sod-kind]").length); + const kinds = $$("[data-sod-kind]"); out.screen = { kinds: kinds.length, withOptions: def.types.filter((t) => t.options.length).length, - pipeTag: $('[data-options-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null, + pipeTag: $('[data-sod-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null, }; out.ok = out.def.types === 37 && diff --git a/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js b/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js new file mode 100644 index 000000000..c5f3b47fd --- /dev/null +++ b/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js @@ -0,0 +1,145 @@ +/* 48-2 — M02 구조물 옵션 정의 편집 화면(군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 지난 판) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안) + * 쓰는 것: 없음 — 화면 사본만 고치고 [저장] 안 누름(저장 길은 test_m02_structure_def_screen.py 가 사본으로) · + * 끝에 판이 그대로인지 GET 으로 봄 · 화면을 다시 띄우면 고침은 사라짐 + * 기대 수치: + * - families = 구조물군 수 · kinds = 37 · pipe 줄 options = pipe 옵션 수 · cells = 쓰는 옵션 수 · base = true + * - 이름 바꿈 → 왼쪽 단추 글 따라감 · [저장] 켜짐 + * - 선택지 펼침 = 3(흄관 · VR관 · 파형강관) · 「흄관」 → 「흄관X」 = 기본값 고르기에도 「흄관X」 + * - 너비 2 → 미리보기 span 2 · 겹치게 두면 issues 에 「배치 겹침」 · [저장] 꺼짐 · 되돌리면 사라짐 + * - 기준 측점 끔 → 미리보기 기준 측점 칸 0 + * - 그룹 형상 「길이」 켬 → 뜻 칸 열림 + * - [규칙 더함] → 규칙 줄 1 · 빨강 아님 · 지우기 → 0 + * - [지난 판] → 목록 칸(판 단추 또는 「지난 판 없음」) + * - 끝 판 = 처음 판(저장 안 함) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 120) { + const v = fn(); + if (v) return v; + await W(120); + } + return null; + }; + /** 칸 값 바꿈 — 글자 넣음(input) + 떠남(change) */ + const put = (box, value) => { + box.value = value; + box.dispatchEvent(new Event("input", { bubbles: true })); + box.dispatchEvent(new Event("change", { bubbles: true })); + }; + const tick = (box, on) => { + if (box.checked !== on) box.click(); + }; + const def = await ( + await fetch("/api/m02/structure-definition", { credentials: "include" }) + ).json(); + if (!def.types) return JSON.stringify({ error: "정의 못 읽음" }); + const pipe = def.types.find((t) => t.type_id === "pipe"); + const out = { 판: def.판 }; + + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ error: "정의 줄 없음" }); + row.click(); + if (!(await until(() => $$("[data-sod-kind]").length))) + return JSON.stringify({ error: "종류 목록 없음" }); + out.families = [$$("[data-sod-family]").length, Object.keys(def.구조물군).length]; + out.kinds = $$("[data-sod-kind]").length; + $('[data-sod-kind="pipe"]').click(); + await until(() => $('[data-sod-option="pipe_kind"]')); + const liveOpts = pipe.options.filter((o) => o.enabled !== false).length; + out.pipe = { + options: [$$("[data-sod-option]").length, pipe.options.length], + cells: [$$("[data-sod-cell]").length, liveOpts], + base: !!$(".m02-sod__cell.is-base"), + }; + + // 이름 바꿈 → 왼쪽 단추 · [저장] 켜짐 + put($("[data-sod-kind-name]"), "배수관(시험)"); + out.rename = { + left: $('[data-sod-kind="pipe"]').innerText.includes("배수관(시험)"), + save: !$("[data-sod-save]").disabled, + }; + + // 선택지 펼침 · 이름 바꿈 → 기본값 고르기에도 + $('[data-sod-choices="pipe_kind"]').click(); + await until(() => $$("[data-sod-choice]").length); + out.choices = $$("[data-sod-choice]").map((r) => $("input", r).value); + put($('[data-sod-choice="흄관"] [data-sod-choice-name]'), "흄관X"); + const defaultPick = $('[data-sod-option="pipe_kind"] [data-sod-default]'); + out.choiceRename = [...defaultPick.options].some((o) => o.value === "흄관X"); + + // 배치 — 너비 2 · 겹침 · 되돌림 + put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "2"); + const cell = $('[data-sod-cell="pipe_kind"]'); + out.width2 = cell.style.gridColumn.replace(/\s+/g, " "); + const other = pipe.options.filter((o) => o.enabled !== false)[1].key; + put($(`[data-sod-place-row="${other}"] [data-sod-row]`), "1"); + put($(`[data-sod-place-row="${other}"] [data-sod-col]`), "2"); + out.overlap = { + issue: $("[data-sod-issues]").innerText.includes("배치 겹침"), + red: $$(".m02-sod__cell.is-bad").length, + saveOff: $("[data-sod-save]").disabled, + }; + put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "1"); + out.overlapGone = !$("[data-sod-issues]").innerText.includes("배치 겹침"); + + // 기준 측점 끔 + tick($("[data-sod-base]"), false); + out.baseOff = $$(".m02-sod__cell.is-base").length; + + // 그룹 형상 「길이」 켬 → 뜻 칸 열림 + const shape = $("[data-sog-shape]"); + const meanBefore = $(".m02-sog__mean", shape).disabled; + tick($("[data-sog-shape-0]", shape), true); + out.shape = { before: meanBefore, after: $(".m02-sog__mean", $("[data-sog-shape]")).disabled }; + + // 규칙 더함 · 지우기 + $("[data-sod-rule-add]").click(); + const rule = await until(() => $("[data-sod-rule]")); + out.rule = { + rows: $$("[data-sod-rule]").length, + bad: rule.classList.contains("is-bad"), + top: $("[data-sod-rule-top]", rule).value, + target: $("[data-sod-rule-target]", rule).value, + }; + [...$$("button", rule)].find((b) => b.innerText.trim() === "지우기").click(); + out.ruleGone = $$("[data-sod-rule]").length; + + // 지난 판 목록 + $("[data-sod-history-toggle]").click(); + await until(() => $("[data-sod-history]").children.length); + out.history = $("[data-sod-history]").innerText.replace(/\s+/g, " ").trim().slice(0, 80); + + const after = await ( + await fetch("/api/m02/structure-definition", { credentials: "include" }) + ).json(); + out.판그대로 = after.판 === def.판; + out.ok = + out.families[0] === out.families[1] && + out.kinds === 37 && + out.pipe.options[0] === out.pipe.options[1] && + out.pipe.cells[0] === out.pipe.cells[1] && + out.pipe.base && + out.rename.left && + out.rename.save && + out.choices.length === 3 && + out.choiceRename && + out.width2.includes("span 2") && + out.overlap.issue && + out.overlap.saveOff && + out.overlapGone && + out.baseOff === 0 && + out.shape.before === true && + out.shape.after === false && + out.rule.rows === 1 && + !out.rule.bad && + out.ruleGone === 0 && + out.history.length > 0 && + out.판그대로; + return JSON.stringify(out); +})();