diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index f5396e6e..4a6403af 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -32,9 +32,6 @@ from B05_Profile.B05_Profile_Structures_Schema import ( StructureListResponse, StructureSaveRequest, StructureSaveResponse, - StructureTypesResponse, - load_structure_types, - registry_schema_version, ) from common_util.common_util_drainage_pipes import ( PipePoint, @@ -44,6 +41,7 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible logger = logging.getLogger(__name__) @@ -58,13 +56,10 @@ _PROJECT_PATH_MISSING = { } -@router.get("/structure-types", response_model=StructureTypesResponse) -async def read_structure_types() -> StructureTypesResponse: - """구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용).""" - return StructureTypesResponse( - schema_version=registry_schema_version(), - types=list(load_structure_types()), - ) +@router.get("/structure-types", response_model=TypesWithVisible) +async def read_structure_types() -> TypesWithVisible: + """구조물 타입 레지스트리 정본 + 옵션마다 `visible`(M02 옵션 보이기 · 폼 생성용).""" + return with_visible() async def _project_root(project_id: UUID) -> str | None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a1a560cb..2fc61e8a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -250,6 +250,29 @@ export const editMaterialCombo = ( export const deleteMaterialCombo = (키: string, 판: string): Promise => call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" }); +/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */ +export interface OptionKind { + type_id: string; + group: string; + name: string; + /** 거짓이면 B05 선택지에 안 나오는 종류 */ + enabled: boolean; + options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[]; +} + +/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */ +export interface OptionsDoc { + 판: string; + 표시: Record>; + 종류: OptionKind[]; +} + +export const fetchStructureOptions = (): Promise => call("/structure-options"); + +/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ +export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => + call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options.py b/M02_MasterTemplete/M02_MasterTemplete_Options.py new file mode 100644 index 00000000..8936080b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options.py @@ -0,0 +1,149 @@ +"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크. + +정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}` +— 없는 종류 · 키는 보임(파일이 없어도 모두 보임). +저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`. +옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`). +판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from pydantic import Field + +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureOptionField, + StructureType, + StructureTypesResponse, + load_structure_types, + registry_schema_version, +) +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +FILE = "구조물옵션표시.json" +logger = logging.getLogger(__name__) + + +class OptionWithVisible(StructureOptionField): + """폼 옵션 한 칸 + 보임 여부(서버가 채움).""" + + visible: bool = True + + +class TypeWithVisible(StructureType): + options: list[OptionWithVisible] = Field(default_factory=list) + + +class TypesWithVisible(StructureTypesResponse): + """`GET /structure-types` 응답 — 옵션마다 `visible` 칸.""" + + types: list[TypeWithVisible] + + +def _load() -> tuple[dict[str, Any], str]: + """(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글.""" + path = store.FOLDER / FILE + if not path.is_file(): + return {}, "" + raw = path.read_bytes() + try: + doc = json.loads(raw) + except ValueError as e: + raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e + if not isinstance(doc, dict): + raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함") + return doc, store.version_of(raw) + + +def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool: + row = doc.get(type_id) + return not (isinstance(row, dict) and row.get(key) is False) + + +def with_visible() -> TypesWithVisible: + """레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만).""" + try: + doc = _load()[0] + except store.StoreError as e: + logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail) + doc = {} + types = [ + { + **t.model_dump(), + "options": [ + {**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options + ], + } + for t in load_structure_types() + ] + return TypesWithVisible.model_validate( + {"schema_version": registry_schema_version(), "types": types} + ) + + +def listing() -> dict[str, Any]: + """`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션.""" + doc, version = _load() + hidden: dict[str, dict[str, bool]] = {} + kinds = [] + for t in load_structure_types(): + if not t.options: + continue + off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)} + if off: + hidden[t.type_id] = off + kinds.append( + { + "type_id": t.type_id, + "group": t.group, + "name": t.name, + "enabled": t.enabled, + "options": [ + {"key": o.key, "label": o.label, "phase": o.phase, "required": o.required} + for o in t.options + ], + } + ) + return {"판": version, "표시": hidden, "종류": kinds} + + +def _clean(shown: Any) -> dict[str, dict[str, bool]]: + """받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422.""" + if not isinstance(shown, dict): + raise store.StoreError(400, "표시는 종류별 묶음이어야 함") + keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()} + errors: list[str] = [] + out: dict[str, dict[str, bool]] = {} + for type_id, row in shown.items(): + if type_id not in keys: + errors.append(f"없는 구조물 종류 「{type_id}」") + elif not isinstance(row, dict): + errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함") + else: + for key, value in row.items(): + if key not in keys[type_id]: + errors.append(f"「{type_id}」 에 없는 옵션 「{key}」") + elif not isinstance(value, bool): + errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함") + elif not value: + out.setdefault(type_id, {})[key] = False + if errors: + raise store.StoreError(422, {"errors": errors}) + return out + + +def save(shown: Any, version: str) -> dict[str, Any]: + """고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`.""" + fixed = _clean(shown) + path = store.FOLDER / FILE + with store._LOCK: + have = store.version_of(path.read_bytes()) if path.is_file() else "" + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + atomic_write_json(path, fixed) + return listing() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options.ts b/M02_MasterTemplete/M02_MasterTemplete_Options.ts new file mode 100644 index 00000000..3a8c998b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options.ts @@ -0,0 +1,220 @@ +/* ============================================================================= + * M02_MasterTemplete_Options.ts + * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에 + * 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) · + * [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). + * 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장). + * ========================================================================== */ + +import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; +import { + fetchStructureOptions, + saveStructureOptions, + StaleError, + type OptionKind, + type OptionsDoc, +} from "./M02_MasterTemplete_Api_Fetch"; +import { to } from "./M02_MasterTemplete_Options_Text"; + +export interface OptionsSectionOptions { + isAdmin: boolean; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface OptionsSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + 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", + text: to("Row"), + 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; + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + let doc: OptionsDoc; + try { + doc = await fetchStructureOptions(); + } catch (error) { + return showToast(to("Load_Failed", why(error)), "error"); + } + draw(doc); + } + + /** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ + function draw(doc: OptionsDoc): void { + let version = doc.판; + 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))); + }; + load(doc.표시); + let kinds = doc.종류; + let current: OptionKind | null = null; + + 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 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 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 }), + ...(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 paintOptions = (): void => { + if (!current) { + optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); + return; + } + const kind = current; + optionList.replaceChildren( + el("h3", { className: "m02-matset__head", text: kind.name }), + ...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); + setDirty(true); + 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("label", { + className: "m02-opts__option", + children: [ + box, + el("span", { text: o.label }), + el("small", { className: "m02-opts__key", text: o.key }), + ...tags, + ], + }); + }), + ); + }; + + 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(); + setDirty(true); + paintKinds(); + paintOptions(); + }); + save.addEventListener("click", async () => { + const 표시: OptionsDoc["표시"] = {}; + for (const [id, keys] of hidden) + if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); + save.disabled = true; // 연달아 눌러도 한 번만 + try { + const done = await saveStructureOptions(version, 표시); + version = done.판; + load(done.표시); + setDirty(false); + showToast(to("Saved"), "success"); + paintKinds(); + paintOptions(); + } catch (error) { + if (error instanceof StaleError) { + showToast(to("Stale"), "error"); + try { + const fresh = await fetchStructureOptions(); + version = fresh.판; + kinds = fresh.종류; + load(fresh.표시); + setDirty(false); + current = kinds.find((k) => k.type_id === current?.type_id) ?? null; + paintKinds(); + paintOptions(); + } catch (again) { + showToast(to("Load_Failed", why(again)), "error"); + } + } 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); + paintKinds(); + paintOptions(); + opt.onOpen(); + mark(true); + } + + return { title: to("Title"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts new file mode 100644 index 00000000..365d8c3a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * M02_MasterTemplete_Options_Text.ts + * 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Title: ["구조물 옵션 보이기", "Structure options shown"], + Row: ["종류마다 보일 옵션 고르기", "Choose options per type"], + Screen: ["구조물 옵션 보이기", "Structure options shown"], + Note: [ + "체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임", + "Only checked options appear in the B05 · B06 forms — definitions and stored values stay", + ], + 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: [ + "필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음", + "Required — hiding it leaves the B05 form unable to set it", + ], + 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}"], +} as const satisfies Record; + +export type OptionsTextKey = keyof typeof TEXT; + +export function to(key: OptionsTextKey, value = ""): string { + const entry = TEXT[key]; + return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 2c85e265..8a4ebfe6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from M02_MasterTemplete import M02_MasterTemplete_Branch as branch from M02_MasterTemplete import M02_MasterTemplete_Matset as matset +from M02_MasterTemplete import M02_MasterTemplete_Options as options from M02_MasterTemplete import M02_MasterTemplete_Store as store router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"]) @@ -41,6 +42,11 @@ class MatsetBody(BaseModel): 줄: dict[str, Any] +class OptionsBody(BaseModel): + 판: str = "" # 옵션 표시 표 판 — 어긋나면 409 + 표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김 + + def _call(fn, *args): try: return fn(*args) @@ -127,6 +133,19 @@ def delete_material_combo(key: str, 판: str = "") -> dict: return _call(matset.delete, key, 판) +# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`) +@router.get("/structure-options") +def get_structure_options() -> dict: + """`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션.""" + return _call(options.listing) + + +@router.put("/structure-options") +def put_structure_options(body: OptionsBody) -> dict: + """고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`.""" + return _call(options.save, body.표시, body.판) + + # 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로 @router.post("/structures/{column}/copy") def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict: diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 18121691..f765c1f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -36,6 +36,7 @@ import { type StructureColumn, } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; +import { buildOptionsSection } from "./M02_MasterTemplete_Options"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -122,13 +123,20 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => paint(), + onOpen: () => (options.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (matsetSection.classList.contains("is-collapsed")) void matset.refresh(); }); + // 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크) + const options = buildOptionsSection({ + isAdmin: canEdit(layer, opt.isAdmin), + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (matset.clear(), paint()), + }); // 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) const summary = section( L("M02_KindTable"), @@ -142,6 +150,7 @@ export function buildSide(opt: SideOptions): SideHandle { className: "m02-side__lists ui-sidebar-panel", children: [ summary, + section(options.title, options.body), section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), ], }); @@ -217,6 +226,7 @@ export function buildSide(opt: SideOptions): SideHandle { paintColumns(); paintButtons(); if (opt.getOpen()) matset.clear(); + if (opt.getOpen()) options.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index ac566224..57c1705e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -123,3 +123,73 @@ color: var(--color-text); font-size: var(--text-body); } + +/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */ +.m02-opts { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + max-width: 64rem; + font-size: var(--text-body-sm); +} + +.m02-opts__panes { + display: grid; + grid-template-columns: minmax(14rem, 20rem) 1fr; + gap: var(--spacing-16); + align-items: start; +} + +.m02-opts__kinds, +.m02-opts__list { + max-height: calc(100vh - 20rem); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-opts__group { + margin: var(--spacing-8) 0 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-opts__kind { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-opts__kind:hover { + background: var(--color-mist-violet); +} + +.m02-opts__kind.is-active { + border-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.m02-opts__option { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; + cursor: pointer; +} + +.m02-opts__key { + color: var(--color-text-muted); + font-size: 11px; +} diff --git a/resources/master_template/구조물옵션표시.json b/resources/master_template/구조물옵션표시.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/resources/master_template/구조물옵션표시.json @@ -0,0 +1 @@ +{} diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py new file mode 100644 index 00000000..e856d406 --- /dev/null +++ b/resources/tester/test_m02_structure_options.py @@ -0,0 +1,129 @@ +"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`). + +못박는 것 + - 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible. + - 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜. + - 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409. + - 파일이 깨져도 폼 응답은 모두 보임(죽지 않음). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile.B05_Profile_Structures_Router import router as b05_router +from B05_Profile.B05_Profile_Structures_Schema import load_structure_types +from M02_MasterTemplete import M02_MasterTemplete_Options as opt +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router + +ROOT = Path(__file__).resolve().parents[2] +URL = "/api/m02/structure-options" +TYPES = "/api/projects/structure-types" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(m02_router) + app.include_router(b05_router) + return TestClient(app) + + +def _pick() -> tuple[str, str, str]: + """옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키).""" + t = next(t for t in load_structure_types() if len(t.options) >= 2) + return t.type_id, t.options[0].key, t.options[1].key + + +def _visible(client: TestClient) -> dict[str, dict[str, bool]]: + types = client.get(TYPES).json()["types"] + return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types} + + +def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None: + assert ( + json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8")) + == {} + ) + got = client.get(URL).json() + assert got["판"] == "" and got["표시"] == {} # 파일 없음 + assert all(all(v.values()) for v in _visible(client).values()) + assert client.get(TYPES).json()["schema_version"] == 1 + + +def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None: + tid, off, on = _pick() + before = client.get(TYPES).json()["types"] + res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}}) + assert res.status_code == 200, res.text + assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김 + seen = _visible(client) + assert seen[tid][off] is False and seen[tid][on] is True + assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로 + after = client.get(TYPES).json()["types"] + strip = lambda ts: [ # noqa: E731 + {**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]} + for t in ts + ] + assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜 + kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid) + assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0] + + +def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None: + tid, off, _ = _pick() + v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"] + res = client.put(URL, json={"판": v1, "표시": {}}) + assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1 + assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {} + assert all(all(v.values()) for v in _visible(client).values()) + + +def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None: + tid, off, _ = _pick() + for bad in ( + {"없는종류": {off: False}}, + {tid: {"없는키": False}}, + {tid: {off: "no"}}, + {tid: [off]}, + ): + assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad + assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양) + assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀 + v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"] + assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409 + assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기 + assert client.get(URL).json()["판"] == v1 + + +def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None: + (store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8") + assert all(all(v.values()) for v in _visible(client).values()) + assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림 + + +def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None: + side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") + assert "section(options.title, options.body)" in side + assert ( + side.index(" summary,") + < side.index("section(options.title") + < side.index('section(L("M02_KindDrawing")') + ) + assert "onOpen: () => (options.clear(), paint())" in side # 자재 조합을 열면 이 줄 고름 지움 + assert "onOpen: () => (matset.clear(), paint())" in side # 이 줄을 열면 자재 조합 고름 지움 + paint = side.split("function paint(): void {")[1].split("\n }\n")[0] + assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 + api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") + assert ( + 'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api + ) + ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8") + assert 'o.phase === "detail"' in ui # B06 전용 표시