diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 10d353761..20014b367 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -22,6 +22,8 @@ import { linkOf, readState, solveSplit, + specContainerHidden, + specItemHidden, TEXT_MAX, writeOptions, type SpecContainer, @@ -82,6 +84,8 @@ export interface SpecForm { groupNames?: Record, /** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */ defaults?: Record, + /** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */ + hidden?: ReadonlySet, ) => void; read: () => Record; state: () => SpecState; @@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { let names: SpecNames = {}; let groupNames: Record = {}; let defaults: Record = {}; + let hidden: ReadonlySet = new Set(); const recent = new Map(); const slots = new Map(); const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; @@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { } const fields = c.items .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) + .filter((item) => !specItemHidden(item, hidden)) .map((item) => { const input = control(c, item, view, tags.get(item.id)); const wrap = labeled(itemLabel(c, item, names), input); @@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const fitted: SpecContainer[] = []; for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); + if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2) if (fit(c, list)) fitted.push(c); shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; @@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return { root, - write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) { + write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) { spec = next; defaults = nextDefaults; + hidden = nextHidden ?? new Set(); names = nextNames; groupNames = nextGroups; state = readState(next, options); diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 3ba11d1d1..4e6ff6549 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string => const text = (value: unknown): string => value === undefined || value === null ? "" : String(value); +/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */ +export const specItemKeys = (item: SpecItem): string[] => + [item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter( + (key): key is string => !!key, + ); + +/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */ +export const specItemHidden = (item: SpecItem, hidden: ReadonlySet): boolean => { + const keys = specItemKeys(item); + return keys.length > 0 && keys.every((key) => hidden.has(key)); +}; + +/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */ +export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet): boolean => + c.items.some((item) => specItemKeys(item).length > 0) && + c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden)); + /** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */ export function readState( spec: StructureSpec, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index daf7bb4cc..570cc0018 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -141,6 +141,8 @@ export interface StructureListResponse { project_id: string; revision: number; structures: StructureInstance[]; + /** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */ + definition_version?: number | null; } export interface StructureSaveResponse { @@ -211,6 +213,21 @@ export async function dropStaleStructureTypes(): Promise { return true; } +const noticed = new Set(); + +/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */ +export function staleDefinitionNotice( + projectId: string, + saved: number | null | undefined, +): string | null { + if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) + return null; + const key = `${projectId}:${saved}:${typesVersion}`; + if (noticed.has(key)) return null; + noticed.add(key); + return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`; +} + const staleListeners = new Set<() => void>(); function checkStaleOnShow(): void { diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index bc2ad31ed..7629a36a4 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]: return revision, structures +def load_saved_definition_version(project_root: str) -> int | None: + """정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None.""" + path = structures_file_path(project_root) + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle).get("definition_version") + except (OSError, ValueError, TypeError, AttributeError): + return None + return value if isinstance(value, int) and not isinstance(value, bool) else None + + def load_migrated_legacy(project_root: str) -> set[str]: """이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합. diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 4a0912699..2114b94ce 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stati from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_migrated_legacy, + load_saved_definition_version, load_structures, requires_downstream_invalidation, save_structures, @@ -159,7 +160,10 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, structures = load_structures(root) return StructureListResponse( - project_id=str(project_id), revision=revision, structures=structures + project_id=str(project_id), + revision=revision, + structures=structures, + definition_version=load_saved_definition_version(root), ) except LookupError: # 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다. diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 8f4e42d95..cbdcb9202 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -305,3 +305,5 @@ class StructureListResponse(BaseModel): project_id: str revision: int structures: list[StructureInstance] = Field(default_factory=list) + # 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림 + definition_version: int | None = None diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 7a1b435ea..876d5f38d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -50,7 +50,7 @@ import { outletTiersRow, revetChoiceRow, } from "./B05_Profile_UI_Drainage_Revet_Choice"; -import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; +import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { createSpecForm, type SpecForm, @@ -607,6 +607,7 @@ export function createFacilityOptionsForm( const names = definition?.spec_names ?? {}; // 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈 const defaults = optionDefaults(definition?.options ?? []); + const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2) if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; @@ -616,11 +617,18 @@ export function createFacilityOptionsForm( !["revet", "I", "L", "U"].includes(String(kind ?? "")) ? { structure: kind } : null; - pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults); + pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden); } if (facility === "box_culvert") { // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 - boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults)); + boxForm.write( + BOX_SPEC, + boxOptionsIn(options), + names, + groups, + boxOptionsIn(defaults), + hidden, + ); for (const [id, value] of Object.entries(bodyOf(options))) boxForm.setValue("body", id, value); } @@ -631,9 +639,10 @@ export function createFacilityOptionsForm( names, groups, withLegacyGuards(defaults), + hidden, ); if (facility === "ford_pavement") - paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults); + paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden); if (facility === "ford_bridge") fordFlowBox.fill(); if (facility === "ford_pavement") paveFlowBox.fill(); wingInFields.write(options); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index e9080f96d..1dc736ccc 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -19,6 +19,7 @@ import { fetchStructures, dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, saveStructures, readPendingStructures, writePendingStructures, @@ -369,6 +370,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ]), ); structureRevision = stored.revision; + const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2 + if (note) showToast(note); // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). const pending = readPending(); ownStructures = pending ?? stored.structures; diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index a0884d884..82e43d2d8 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -101,6 +101,11 @@ export function optionControl( return element; } +/** 정의 「표시」로 숨긴 옵션 키(서버 `visible` 거짓 · 322-2) — 명세 폼이 그 칸 · 상자를 안 그림. */ +export const hiddenOptionKeys = ( + options: ReadonlyArray<{ key: string; visible?: boolean }>, +): Set => new Set(options.filter((o) => o.visible === false).map((o) => o.key)); + /** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */ export function optionDefaults( options: ReadonlyArray<{ key: string; default: string | number | null }>, diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index a9ab220df..261b06514 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -18,7 +18,7 @@ import { import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; -import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; +import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; const forms = new WeakMap(); @@ -86,7 +86,15 @@ export function paintStructureSpec( form.root.hidden = !spec; if (!spec) return; const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); - form.write(spec, values, type?.spec_names ?? {}, groups, optionDefaults(type?.options ?? [])); + const options = type?.options ?? []; + form.write( + spec, + values, + type?.spec_names ?? {}, + groups, + optionDefaults(options), + hiddenOptionKeys(options), + ); } /** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 379f7f71f..58fdee889 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -22,6 +22,7 @@ import { fetchStructures, dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, readPendingStructures, pipesToStructureMarks, structureAnchorM, @@ -384,6 +385,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; + const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2 + if (note) showToast(note); section.setStructures(structures); const detail = deps.detail?.() ?? null; // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py new file mode 100644 index 000000000..9a865d192 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -0,0 +1,169 @@ +"""구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 반영 · 부품 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정. + +- 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것 +- 부품 = `{"갈래.부품": false}`(끈 것만) +- 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 · + 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면 + 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠) +- 앞 판이 없으면(옛 판 읽기) 꼴만 검사 +- 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로) +화면 짝 = `M02_SO_Def_Check.ts`(같은 글) +거울 시험 = `resources/tester/test_322_2_check_mirror.py`. +""" + +from __future__ import annotations + +from typing import Any + +from common_util.common_util_structure_catalog import load_parts, resolve_catalog + +BRANCHES = ("횡단도", "카드이름", "형상", "3D") +OFF_VALUE = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"} +LISTS = ("both", "b06", "hidden") +_FIELDS = {"목록", "형상칸", *BRANCHES} + + +def _shape_errors(doc: dict[str, Any], known: set[str]) -> list[str]: + """반영 · 부품 꼴.""" + parts = load_parts()["parts"] + out: list[str] = [] + reflect = doc.get("반영", {}) + if not isinstance(reflect, dict): + out.append("반영은 {종류: 묶음} 이어야 함") + else: + for tid, row in reflect.items(): + if tid not in known or not isinstance(row, dict): + out.append(f"반영 「{tid}」 — 없는 종류이거나 묶음 아님") + continue + for field, value in row.items(): + where = f"반영 「{tid}.{field}」" + if field not in _FIELDS: + out.append(f"{where} — 모르는 칸") + elif field == "목록": + if value not in LISTS: + out.append(f"{where} — both · b06 · hidden 중 하나") + elif field == "형상칸": + ok = isinstance(value, dict) and all( + (isinstance(v, str) and bool(v.strip())) + if k == "form" + else (k == "weep" and isinstance(v, bool)) + for k, v in value.items() + ) + if not ok: + out.append(f"{where} — form 글 · weep 참거짓") + elif not isinstance(value, str) or ( + value != OFF_VALUE[field] and value not in parts[field] + ): + shown = value if isinstance(value, str) else _js(value) + out.append(f"{where} — 없는 부품 「{shown}」") + off = doc.get("부품", {}) + if not isinstance(off, dict): + out.append("부품은 {갈래.부품: 참거짓} 이어야 함") + else: + for key, value in off.items(): + branch, _, part = key.partition(".") + if part not in parts.get(branch, {}): + out.append(f"부품 「{key}」 — 없는 부품") + elif not isinstance(value, bool): + out.append(f"부품 「{key}」 — 참거짓") + return out + + +def _js(value: Any) -> str: + """TS `String(value)` 와 같은 글(검사 글 짝).""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return ",".join(_js(v) for v in value) + if isinstance(value, dict): + return "[object Object]" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _hidden_in(doc: dict[str, Any] | None, tid: str) -> list[str]: + row = ((doc or {}).get("표시") or {}).get(tid) or {} + return [key for key, shown in row.items() if shown is False] + + +def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]: + """표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만).""" + parts = load_parts()["parts"] + rows = resolve_catalog(doc) + out: list[str] = [] + for t in doc.get("types") or []: + row = rows.get(t["type_id"]) + if row is None: + continue + if row["shape"] == "none" and row["station"] != "none": + out.append(f"「{row['name']}」 형상 none — 횡단도도 none 이어야 함") + kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음 + hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept] + # 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 + keys = { + o["key"] + for o in t.get("options") or [] + if o.get("default") is None and not o.get("empty_means") + } + used = ( + ("횡단도", row["station"]), + ("카드이름", row["title"]), + ("형상", row["shape"]), + ("3D", row["model3d"]), + ) + for branch, part_id in used: + part = parts[branch].get(part_id) + if part is None: + continue + if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]: + out.append( + f"「{row['name']}」 {branch} 「{part_id}」 — 자리꼴 {row['placement']} · " + f"정본 {row['owner']} 에 안 맞음" + ) + for key in part["읽는 칸"]: + if key in keys and key in hidden: + out.append( + f"「{row['name']}」 {branch} 「{part_id}」 이 읽는 칸 「{key}」 — " + "숨길 수 없음" + ) + return out + + +def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: + """앞 판과 비교 — 만든 뒤 고정.""" + out: list[str] = [] + now = {t["type_id"]: t for t in doc.get("types") or []} + for old in before.get("types") or []: + tid = old["type_id"] + t = now.get(tid) + if t is None: + out.append(f"종류 「{tid}」 — 지울 수 없음") + continue + fixed = ( + ("자리꼴", old.get("placement"), t.get("placement")), + ("정본", old.get("managed_by"), t.get("managed_by")), + ("군 글자", old.get("group"), t.get("group")), + ) + for what, a, b in fixed: + if a != b: + out.append(f"「{tid}」 {what} — 만든 뒤 고정") + options = {o["key"]: o for o in t.get("options") or []} + for o in old.get("options") or []: + n = options.get(o["key"]) + if o.get("input") != "select" or n is None: + continue + if sorted(o.get("choices") or []) != sorted(n.get("choices") or []): + out.append(f"「{tid}.{o['key']}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)") + return out + + +def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]: + """구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 판(없으면 고정 검사 안 함).""" + known = {t["type_id"] for t in doc.get("types") or [] if isinstance(t, dict)} + shape = _shape_errors(doc, known) + if shape: + return shape + return _row_errors(doc, before) + _fixed_errors(doc, before) if before else [] diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 1529bacc3..5e7512293 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -1,8 +1,10 @@ /* ============================================================================= * M02_SO_Def.ts - * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) · - * 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) · - * 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄). + * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 탭 셋(322-2): 「한눈 표」(종류마다 반영 한 줄 · + * `M02_SO_Def_Table`) · 「종류별」(왼쪽 구조물군 · 종류 목록 · 오른쪽 고른 종류 이름 · 반영 · 명세 이름 나무 · + * 줄마다 보임 · 미리보기) · 「부품」(`M02_SO_Def_Parts`) · + * 아래 검사(화면 = 서버 짝 `M02_SO_Def_Check`) · [저장](판 +1 · 목록 + enabled 함께 · 시스템 관리자만) · + * 지난 판 목록(누르면 그 판을 읽기만으로 봄). * 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1). * ========================================================================== */ @@ -28,6 +30,10 @@ import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; import { buildSpecBlock } from "./M02_SO_Def_Spec"; import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; +import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check"; +import { buildParts } from "./M02_SO_Def_Parts"; +import { setReflect, syncEnabled } from "./M02_SO_Def_Reflect"; +import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -44,10 +50,12 @@ export interface DefScreenOptions { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ +/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */ function tidy(def: StructDef): StructDef { const out = clone(def); - for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record[]) + syncEnabled(out); + const reflect = (out.반영 ?? {}) as Record; + for (const part of [out.그룹, out.배치, out.종류, out.표시, reflect] 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; @@ -88,6 +96,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { let current: string | null = null; const open = new Set(); // 선택지를 펼친 옵션 let versions: DefVersion[] | null = null; + type Tab = "table" | "kinds" | "parts"; + let tab: Tab = "table"; const shown = (): StructDef => past ?? work; const canEdit = (): boolean => opt.isAdmin && !past; @@ -100,6 +110,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { attrs: { "data-sod-history": "" }, }); + /** 검사 글 — 이름 · 배치 · 규칙(`problems`) + 구조물 표(서버 짝 · 앞 판 = 지금 서버 판) */ + const allProblems = (): string[] => + past ? [] : [...problems(work), ...catalogProblems(work as CheckDef, base as CheckDef)]; + const tabBar = el("div", { className: "m02-sod__tabs", attrs: { "data-sod-tabs": "" } }); + const body = el("div", { className: "m02-sod__body" }); + const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] }); + const changed = (): void => { dirty = true; sync(); @@ -199,10 +216,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { 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", - }), + // 안 씀 = 목록 B06 만(enabled 는 목록 짝 · 322-2) · 숨김(hidden)은 한눈 표 목록 칸에서 + checkBox( + to("Unused"), + type.enabled === false, + (v) => (setReflect(d, type.type_id, "목록", v ? "b06" : "both"), changed()), + { edit, attr: "data-sod-kind-off" }, + ), ...(!OLD_EDITOR ? [] : [ @@ -225,7 +245,10 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ], }), ...(!OLD_EDITOR - ? [buildSpecBlock({ def: d, type, spec, edit, changed })] + ? [ + buildReflectBlock(d, type, edit, changed), + buildSpecBlock({ def: d, base, type, spec, edit, changed }), + ] : [ buildOptionsBlock({ def: d, type, edit, open, changed }), buildLayoutBlock({ def: d, type, edit, changed }), @@ -235,6 +258,27 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ); }; + const paintTabs = (): void => { + const pairs: Array<[Tab, string]> = [ + ["table", to("TabTable")], + ["kinds", to("TabKinds")], + ["parts", to("TabParts")], + ]; + tabBar.replaceChildren( + ...pairs.map(([key, label]) => { + const b = createButton({ label, variant: key === tab ? "filled" : "ghost", size: "sm" }); + b.setAttribute("data-sod-tab", key); + b.addEventListener("click", () => ((tab = key), paint())); + return b; + }), + ); + const edit = canEdit(); + if (tab === "table") + body.replaceChildren(buildTable({ def: shown(), edit, changed, issues: allProblems() })); + else if (tab === "parts") body.replaceChildren(buildParts({ def: shown(), edit, changed })); + else body.replaceChildren(panes); + }; + const paintFoot = (): void => { banner.replaceChildren( ...(past @@ -248,7 +292,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ]), ); banner.classList.toggle("is-past", !!past); - const list = past ? [] : problems(work); + const list = allProblems(); issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t }))); save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0; }; @@ -256,6 +300,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const paint = (): void => { paintKinds(); paintDetail(); + paintTabs(); paintFoot(); }; @@ -273,7 +318,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const save = createButton({ label: to("Save"), variant: "filled" }); save.setAttribute("data-sod-save", ""); save.addEventListener("click", async () => { - if (problems(work).length) return; + if (allProblems().length) return; save.disabled = true; // 연달아 눌러도 한 번만 try { base = await saveStructureDef(base.판, tidy(work)); @@ -339,7 +384,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { children: [ el("p", { className: "m02-side__empty", text: to("DefNote") }), banner, - el("div", { className: "m02-opts__panes", children: [kindList, detail] }), + tabBar, + body, issues, el("div", { className: "m02-side__row", diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts new file mode 100644 index 000000000..1c4583774 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -0,0 +1,192 @@ +/* ============================================================================= + * M02_SO_Def_Check.ts + * 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정. + * + * · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것 + * · 부품 = `{"갈래.부품": false}`(끈 것만) + * · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 · + * 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면 + * 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠) + * · 앞 판이 없으면(옛 판 읽기) 꼴만 검사 + * · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로) + * 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`. + * ========================================================================== */ + +import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" }; +import { + resolveCatalog, + type CatalogDefinition, +} from "../../common_util/common_util_structure_catalog"; + +type Branch = "횡단도" | "카드이름" | "형상" | "3D"; +interface Part { + 이름: string; + "읽는 칸": string[]; + 자리꼴: string[]; + 정본: string[]; +} +const PARTS_BY = (PARTS as unknown as { parts: Record> }).parts; + +export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"]; +/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */ +export const OFF_VALUE: Record = { + 횡단도: "none", + 카드이름: "name", + 형상: "none", + "3D": "none", +}; +export const LISTS = ["both", "b06", "hidden"] as const; +const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]); + +/** 검사가 읽는 정의 칸. */ +export interface CheckDef extends CatalogDefinition { + types?: ReadonlyArray<{ + type_id: string; + name?: string; + group?: string; + placement?: string; + managed_by?: string | null; + options?: ReadonlyArray<{ + key: string; + input?: string; + choices?: readonly string[]; + default?: unknown; + empty_means?: string | null; + }>; + }>; + 표시?: Record>; +} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); +/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */ +const partOf = (branch: string, id: string): Part | undefined => { + const box = (PARTS_BY as Record>)[branch]; + return box && Object.hasOwn(box, id) ? box[id] : undefined; +}; + +/** 반영 · 부품 꼴. */ +function shapeErrors(doc: CheckDef, known: Set): string[] { + const out: string[] = []; + const reflect: unknown = doc.반영 ?? {}; + if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함"); + else + for (const [tid, row] of Object.entries(reflect)) { + if (!known.has(tid) || !isObject(row)) { + out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`); + continue; + } + for (const [field, value] of Object.entries(row)) { + const where = `반영 「${tid}.${field}」`; + if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`); + else if (field === "목록") { + if (!(LISTS as readonly unknown[]).includes(value)) + out.push(`${where} — both · b06 · hidden 중 하나`); + } else if (field === "형상칸") { + const ok = + isObject(value) && + Object.entries(value).every(([k, v]) => + k === "form" + ? typeof v === "string" && !!v.trim() + : k === "weep" && typeof v === "boolean", + ); + if (!ok) out.push(`${where} — form 글 · weep 참거짓`); + } else { + const branch = field as Branch; + if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value))) + out.push(`${where} — 없는 부품 「${String(value)}」`); + } + } + } + const parts: unknown = doc.부품 ?? {}; + if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함"); + else + for (const [key, value] of Object.entries(parts)) { + const at = key.indexOf("."); + if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1))) + out.push(`부품 「${key}」 — 없는 부품`); + else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`); + } + return out; +} + +const hiddenIn = (doc: CheckDef | null, tid: string): string[] => + Object.entries(doc?.표시?.[tid] ?? {}) + .filter(([, shown]) => shown === false) + .map(([key]) => key); + +/** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */ +function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { + const out: string[] = []; + const rows = resolveCatalog(doc); + for (const t of doc.types ?? []) { + const row = rows.get(t.type_id); + if (!row) continue; + if (row.shape === "none" && row.station !== "none") + out.push(`「${row.name}」 형상 none — 횡단도도 none 이어야 함`); + const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음 + const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key)); + // 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 + const keys = new Set( + (t.options ?? []) + .filter((o) => (o.default === null || o.default === undefined) && !o.empty_means) + .map((o) => o.key), + ); + const used: Array<[Branch, string]> = [ + ["횡단도", row.station], + ["카드이름", row.title], + ["형상", row.shape], + ["3D", row.model3d], + ]; + for (const [branch, id] of used) { + const part = partOf(branch, id); + if (!part) continue; + if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner)) + out.push( + `「${row.name}」 ${branch} 「${id}」 — 자리꼴 ${row.placement} · 정본 ${row.owner} 에 안 맞음`, + ); + for (const key of part["읽는 칸"]) + if (keys.has(key) && hidden.includes(key)) + out.push(`「${row.name}」 ${branch} 「${id}」 이 읽는 칸 「${key}」 — 숨길 수 없음`); + } + } + return out; +} + +/** 앞 판과 비교 — 만든 뒤 고정. */ +function fixedErrors(doc: CheckDef, before: CheckDef): string[] { + const out: string[] = []; + const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + for (const old of before.types ?? []) { + const t = now.get(old.type_id); + if (!t) { + out.push(`종류 「${old.type_id}」 — 지울 수 없음`); + continue; + } + const fixed: Array<[string, unknown, unknown]> = [ + ["자리꼴", old.placement, t.placement], + ["정본", old.managed_by ?? null, t.managed_by ?? null], + ["군 글자", old.group, t.group], + ]; + for (const [what, a, b] of fixed) + if (a !== b) out.push(`「${old.type_id}」 ${what} — 만든 뒤 고정`); + const options = new Map((t.options ?? []).map((o) => [o.key, o])); + for (const o of old.options ?? []) { + const n = options.get(o.key); + if (o.input !== "select" || !n) continue; + const a = [...(o.choices ?? [])].sort().join("\u0000"); + const b = [...(n.choices ?? [])].sort().join("\u0000"); + if (a !== b) + out.push(`「${old.type_id}.${o.key}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)`); + } + } + return out; +} + +/** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */ +export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] { + const known = new Set((doc.types ?? []).map((t) => t.type_id)); + const shape = shapeErrors(doc, known); + if (shape.length) return shape; + return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts new file mode 100644 index 000000000..21fc3467b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * M02_SO_Def_Parts.ts + * 구조물 정의 「부품」 탭(PLAN 322-2) — 코드가 그리는 방법(부품 상자 `common_util_structure_parts.json`). + * 갈래마다 부품 · 이름 · 읽는 칸 · 맞는 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 `부품` = 끈 것만). + * 끈 부품을 쓰는 종류는 그 갈래가 없음(카드 이름은 종류 이름)으로 — 표 줄(`resolveCatalog`)이 따름. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { StructDef } from "./M02_SO_Def_Model"; +import { PART_BOX, partsOff, setPartOn, usersOf } from "./M02_SO_Def_Reflect"; +import { checkBox, head } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +type Branch = keyof typeof PART_BOX; + +export function buildParts(opt: { + def: StructDef; + edit: boolean; + changed: () => void; +}): HTMLElement { + const { def, edit, changed } = opt; + const off = partsOff(def); + const blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [ + ...head(branch), + el("table", { + className: "m02-sod__table", + attrs: { "data-sod-parts": branch }, + children: [ + el("thead", { + children: [ + el("tr", { + children: ["", "ColName", "PartRead", "PartFits", "PartUsers", "PartOn"].map((key) => + el("th", { text: key ? to(key as "ColName") : "" }), + ), + }), + ], + }), + el("tbody", { + children: Object.entries(PART_BOX[branch]).map(([id, part]) => { + const key = `${branch}.${id}`; + // 끈 부품을 쓰던 종류도 보이게 — 끈 동안은 표가 none 으로 풀므로 켠 꼴로 셈 + const users = usersOf({ ...def, 부품: {} } as StructDef, branch, id); + return el("tr", { + attrs: { "data-sod-part": key }, + className: off[key] === false ? "is-off" : "", + children: [ + el("td", { children: [el("small", { className: "m02-opts__key", text: id })] }), + el("td", { text: part.이름 }), + el("td", { text: part["읽는 칸"].join(" · ") || "—" }), + el("td", { text: `${part.자리꼴.join(" · ")} / ${part.정본.join(" · ")}` }), + el("td", { text: users.map((row) => row.name).join(" · ") || "—" }), + el("td", { + children: [ + checkBox( + to("PartOn"), + off[key] !== false, + (on) => (setPartOn(def, key, on), changed()), + { edit, attr: "data-sod-part-on" }, + ), + ], + }), + ], + }); + }), + }), + ], + }), + ]); + return el("div", { + className: "m02-sod__tablewrap", + children: [el("p", { className: "m02-side__empty", text: to("PartsNote") }), ...blocks], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts new file mode 100644 index 000000000..b89de8151 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * M02_SO_Def_Reflect.ts + * 구조물 표 편집(PLAN 322-2)의 순수 손질 — 정의 사본의 `반영` · `부품` · 목록 + enabled · 형상칸. + * + * · 지금 값 = 구조물 표 한 줄(`resolveCatalog` · 정의 반영 ⊕ 처음값 ⊕ 부품 끔) + * · 반영에는 처음 값과 다른 칸만 남김(같으면 지움 = 「기본」) · [처음 값으로] = 그 종류 반영 지움 + * · 목록과 enabled 는 함께 씀 — enabled = 목록 both 일 때만 참(옛 짝 · 브레인 322-1 답 ①) + * 화면 그리기 없음 — 표 · 종류별 · 부품 탭이 이 함수들로 사본을 고침. + * ========================================================================== */ + +import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" }; +import { + resolveCatalog, + type CatalogDefinition, + type CatalogRow, +} from "../../common_util/common_util_structure_catalog"; +import type { StructDef } from "./M02_SO_Def_Model"; + +export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D"; +type Reflect = Partial> & { 형상칸?: Record }; +interface Part { + 이름: string; + "읽는 칸": string[]; + 자리꼴: string[]; + 정본: string[]; +} +type Branch = Exclude; + +export const PART_BOX = (PARTS as unknown as { parts: Record> }).parts; +const START = (PARTS as unknown as { 처음값: Record }).처음값; + +/** 칸 → 표 줄 열쇠. */ +const ROW_KEY: Record = { + 목록: "list", + 횡단도: "station", + 카드이름: "title", + 형상: "shape", + "3D": "model3d", +}; + +export const reflectOf = (def: StructDef): Record => + ((def as { 반영?: Record }).반영 ??= {}); +export const partsOff = (def: StructDef): Record => + ((def as { 부품?: Record }).부품 ??= {}); + +/** 사본 전체 표 — 종류마다 한 줄. */ +export const rowsOf = (def: StructDef): Map => + resolveCatalog(def as unknown as CatalogDefinition); + +/** 칸 지금 값. */ +export const valueOf = (row: CatalogRow, field: ReflectField): string => + String(row[ROW_KEY[field]]); + +/** 반영이 없을 때(처음 값 · 목록은 enabled 짝) 그 칸 값. */ +export function defaultOf(def: StructDef, typeId: string, field: ReflectField): string { + const copy = { ...def, 반영: { ...reflectOf(def) } } as unknown as StructDef; + const own = { ...(reflectOf(copy)[typeId] ?? {}) }; + delete own[field]; + reflectOf(copy)[typeId] = own; + const row = rowsOf(copy).get(typeId); + return row ? valueOf(row, field) : ""; +} + +/** 반영에 적힌 칸인가(아니면 「기본」). */ +export const isSet = (def: StructDef, typeId: string, field: ReflectField | "형상칸"): boolean => + field in (reflectOf(def)[typeId] ?? {}); + +const dropEmpty = (def: StructDef, typeId: string): void => { + const all = reflectOf(def); + if (all[typeId] && !Object.keys(all[typeId]).length) delete all[typeId]; +}; + +/** 칸 고침 — 처음 값과 같으면 반영에서 지움 · 목록이면 enabled 도(both 일 때만 참). */ +export function setReflect( + def: StructDef, + typeId: string, + field: ReflectField, + value: string, +): void { + const type = def.types.find((t) => t.type_id === typeId); + if (field === "목록" && type) type.enabled = value === "both"; + const own = (reflectOf(def)[typeId] ??= {}); + delete own[field]; + if (defaultOf(def, typeId, field) !== value) own[field] = value; + dropEmpty(def, typeId); +} + +/** 형상칸 한 칸 고침(form 글 · weep 참거짓) — 처음 값과 같으면 지움. */ +export function setShapeOpt(def: StructDef, typeId: string, key: string, value: unknown): void { + const own = (reflectOf(def)[typeId] ??= {}); + const opts = { ...(own.형상칸 ?? {}) }; + const start = START[typeId]?.형상칸?.[key]; + if (value === start || value === "" || (value === false && start === undefined)) delete opts[key]; + else opts[key] = value; + if (Object.keys(opts).length) own.형상칸 = opts; + else delete own.형상칸; + dropEmpty(def, typeId); +} + +/** [처음 값으로] — 그 종류 반영 지움 · enabled = 처음 목록 both 일 때만 참. */ +export function resetRow(def: StructDef, typeId: string): void { + delete reflectOf(def)[typeId]; + const type = def.types.find((t) => t.type_id === typeId); + if (type) type.enabled = START[typeId] ? START[typeId].목록 === "both" : type.enabled; +} + +/** [저장] 전 — 모든 종류 enabled = 지금 목록 both 일 때만 참(목록 + enabled 함께 씀). */ +export function syncEnabled(def: StructDef): void { + const rows = rowsOf(def); + for (const type of def.types) { + const row = rows.get(type.type_id); + if (row) type.enabled = row.list === "both"; + } +} + +/** 부품 켬끔 — 끔만 남김. */ +export function setPartOn(def: StructDef, key: string, on: boolean): void { + const off = partsOff(def); + if (on) delete off[key]; + else off[key] = false; +} + +/** 못 숨김 칸 — 종류가 지금 쓰는 부품들이 읽는 칸 중 기본값도 비워 두는 뜻도 없는 옵션 + * (숨기면 그림이 받을 값이 없음 · 검사 짝 `M02_SO_Def_Check`). */ +export function readKeys(def: StructDef, typeId: string): Set { + const row = rowsOf(def).get(typeId); + if (!row) return new Set(); + const type = def.types.find((t) => t.type_id === typeId); + const bare = new Set( + (type?.options ?? []) + .filter((o) => (o.default === null || o.default === undefined) && !o.empty_means) + .map((o) => o.key), + ); + const used: Array<[Branch, string]> = [ + ["횡단도", row.station], + ["카드이름", row.title], + ["형상", row.shape], + ["3D", row.model3d], + ]; + return new Set( + used + .flatMap(([branch, id]) => PART_BOX[branch][id]?.["읽는 칸"] ?? []) + .filter((key) => bare.has(key)), + ); +} + +/** 그 갈래 부품을 지금 쓰는 종류들. */ +export function usersOf(def: StructDef, branch: Branch, id: string): CatalogRow[] { + const field = ROW_KEY[branch]; + return [...rowsOf(def).values()].filter((row) => row[field] === id); +} + +/** 정의 「표시」 숨김 키(그 종류). */ +export const hiddenKeys = (def: StructDef, typeId: string): Set => + new Set( + Object.entries(def.표시[typeId] ?? {}) + .filter(([, shown]) => shown === false) + .map(([key]) => key), + ); + +/** 옵션 키들 보임 켬끔 — 정의 옵션인 키만(숨김 = false 만 남김). */ +export function setShown( + def: StructDef, + typeId: string, + keys: readonly string[], + on: boolean, +): void { + const type = def.types.find((t) => t.type_id === typeId); + const known = new Set((type?.options ?? []).map((o) => o.key)); + const row = (def.표시[typeId] ??= {}); + for (const key of keys.filter((k) => known.has(k))) { + if (on) delete row[key]; + else row[key] = false; + } + if (!Object.keys(row).length) delete def.표시[typeId]; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts index c3f5ab458..425295186 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -6,11 +6,13 @@ * 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품 * (`field` · `optionControl` · 그룹 상자)으로 같은 차례. * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. + * 줄마다 보임(322-2 · 정의 「표시」 · 컨테이너 = 그 안 칸 전부) · 부품이 읽는 칸 = 「못 숨김」(앞 판에서 + * 이미 숨긴 칸은 다시 보이기만) · 미리보기도 숨김을 따름. * ========================================================================== */ import { createTag, el } from "@ui/ui_template_elements"; import { createSpecForm } from "../../A00_Common/design/design_structure_form"; -import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import { specItemKeys, type StructureSpec } from "../../A00_Common/design/design_structure_spec"; import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; @@ -20,7 +22,8 @@ import { specNameRows, type SpecNameRow, } from "./M02_SO_Def_Spec_Model"; -import { head, textBox } from "./M02_SO_Def_Ui"; +import { hiddenKeys, readKeys, setShown } from "./M02_SO_Def_Reflect"; +import { checkBox, head, textBox } from "./M02_SO_Def_Ui"; import { to, type OptionsTextKey } from "./M02_SO_Text"; const TAG: Record, OptionsTextKey> = { @@ -36,7 +39,8 @@ const TAG: Record, OptionsTextKey> = { /** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */ function groupPreview(def: StructDef, type: TypeDef): HTMLElement { const names = def.종류[type.type_id]?.이름 ?? {}; - const options = type.options.filter((o) => o.enabled !== false); + const hidden = hiddenKeys(def, type.type_id); + const options = type.options.filter((o) => o.enabled !== false && !hidden.has(o.key)); const cell = (o: (typeof options)[number]): HTMLElement => field( o.unit ? `${o.label} (${o.unit})` : o.label, @@ -67,8 +71,45 @@ function groupPreview(def: StructDef, type: TypeDef): HTMLElement { }); } +/** 이름 줄이 쓰는 정의 옵션 키 — 명세 상자 = 그 안 칸 전부 · 칸 = 저장 · 갈래 키 · 그룹 = 그 옵션들. */ +function rowKeys(type: TypeDef, spec: StructureSpec | undefined, nameKey: string): string[] { + if (!nameKey || nameKey.includes("=")) return []; + if (!spec) { + if (!nameKey.startsWith("#")) return [nameKey]; + return []; + } + const [cid, iid] = nameKey.split("."); + const c = spec.containers.find((x) => x.id === cid); + const items = (c?.items ?? []).filter((i) => !iid || i.id === iid); + const own = new Set(type.options.map((o) => o.key)); + return items.flatMap(specItemKeys).filter((key) => own.has(key)); +} + +/** 줄 보임 칸 — 키 없는 줄은 비움 · 부품이 읽는 보이는 칸은 못 숨김. */ +function shownBox( + opt: { def: StructDef; base: StructDef; type: TypeDef; edit: boolean; changed: () => void }, + keys: string[], +): HTMLElement[] { + if (!keys.length) return []; + const { def, base, type, edit, changed } = opt; + const hidden = hiddenKeys(def, type.type_id); + const kept = hiddenKeys(base, type.type_id); + const read = readKeys(def, type.type_id); + const shown = keys.some((key) => !hidden.has(key)); + const locked = shown && keys.some((key) => read.has(key) && !kept.has(key)); + return [ + checkBox(to("ShownCol"), shown, (v) => (setShown(def, type.type_id, keys, v), changed()), { + edit: edit && !locked, + attr: "data-sod-spec-shown", + }), + ...(locked ? [createTag(to("CannotHide"), "neutral")] : []), + ]; +} + export function buildSpecBlock(opt: { def: StructDef; + /** 지금 서버 판 — 이미 숨긴 칸(못 숨김 예외)을 가름 */ + base: StructDef; type: TypeDef; /** 없으면 정의 그룹 꼴(52-12) */ spec: StructureSpec | undefined; @@ -90,6 +131,12 @@ export function buildSpecBlock(opt: { : el("span", { text: to("SpecLoose") }), el("small", { className: "m02-opts__key", text: row.key }), ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), + ...shownBox( + opt, + row.group && !spec + ? ((def.그룹[type.type_id] ?? []).find((g) => g.id === row.group)?.옵션 ?? []) + : rowKeys(type, spec, row.nameKey), + ), ], }), ); @@ -98,7 +145,14 @@ export function buildSpecBlock(opt: { if (spec) { const form = createSpecForm(null); const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); - form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + form.write( + spec, + {}, + def.종류[type.type_id]?.이름 ?? {}, + groups, + {}, + hiddenKeys(def, type.type_id), + ); preview = form.root; } else preview = groupPreview(def, type); preview.inert = true; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts new file mode 100644 index 000000000..0cdb8c753 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * M02_SO_Def_Table.ts + * 구조물 정의 「한눈 표」 탭(PLAN 322-2) — 종류마다 B05 · B06 반영 한 줄. + * 군 · 종류(▲▼) · 이름 · 목록 · 자리꼴 · 정본(읽기만 「고정」) · 횡단도 · 카드 이름 · 형상(+ 형상칸) · + * 알약 색 · 3D · 검사. 흐린 칸 = 처음 값(「기본」) · 진한 칸 = 바꾼 값 · [처음 값으로] = 그 종류 반영 지움. + * 칸 손질 = `M02_SO_Def_Reflect` · 검사 글 = `M02_SO_Def_Check`(서버 짝) · 「종류별」 탭이 같은 칸(`reflectCells`)을 씀. + * ========================================================================== */ + +import { createButton, createTag, el } from "@ui/ui_template_elements"; +import type { CatalogRow } from "../../common_util/common_util_structure_catalog"; +import { stepFamily, stepType, type StructDef, type TypeDef } from "./M02_SO_Def_Model"; +import { + isSet, + PART_BOX, + resetRow, + rowsOf, + setReflect, + setShapeOpt, + valueOf, + type ReflectField, +} from "./M02_SO_Def_Reflect"; +import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +type Pairs = ReadonlyArray; +const LIST_PAIRS = (): Pairs => [ + ["both", to("ListBoth")], + ["b06", to("ListB06")], + ["hidden", to("ListHidden")], +]; +/** 갈래 고르기 — 없음(카드 이름은 종류 이름) + 부품 상자 차례. */ +const partPairs = (branch: Exclude): Pairs => [ + [ + branch === "카드이름" ? "name" : "none", + branch === "카드이름" ? to("TitleName") : to("OffNone"), + ], + ...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, `${id} · ${part.이름}`] as const), +]; + +interface CellCtx { + def: StructDef; + type: TypeDef; + row: CatalogRow; + edit: boolean; + changed: () => void; +} + +/** 반영 칸 하나 — 처음 값이면 흐리게 「기본」 · 바꿨으면 진하게. */ +function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement { + const { def, type, row, edit, changed } = ctx; + const pairs = field === "목록" ? LIST_PAIRS() : partPairs(field); + const box = pickBox( + pairs, + valueOf(row, field), + (v) => { + setReflect(def, type.type_id, field, v); + changed(); + }, + { edit, attr: `data-sod-reflect-${field}` }, + ); + return mark(isSet(def, type.type_id, field), box); +} + +/** 흐림(기본) · 진함(바꿈) 꼴. */ +function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement { + return el("div", { + className: `m02-sod__cell ${changed ? "is-changed" : "is-default"}`, + children: [...children, ...(changed ? [] : [createTag(to("Default"), "neutral")])], + }); +} + +/** 형상칸 — 벽(형상 wall)만: 기본 형태 글 · 물빼기. */ +function shapeOptsCell(ctx: CellCtx): HTMLElement { + const { def, type, row, edit, changed } = ctx; + if (row.shape !== "wall") return el("span", { className: "m02-side__empty", text: "—" }); + const form = String(row.shapeOpts.form ?? ""); + return mark( + isSet(def, type.type_id, "형상칸"), + textBox( + form, + () => undefined, + () => undefined, + { + edit, + attr: "data-sod-shape-form", + place: to("WallForm"), + max: 20, + }, + ), + checkBox( + to("Weep"), + row.shapeOpts.weep === true, + (v) => (setShapeOpt(def, type.type_id, "weep", v), changed()), + { edit, attr: "data-sod-shape-weep" }, + ), + ); +} + +/** 형상칸 글은 떠날 때 씀(초점 안 끊김). */ +function bindForm(node: HTMLElement, ctx: CellCtx): void { + const box = node.querySelector("[data-sod-shape-form]"); + box?.addEventListener("change", () => { + setShapeOpt(ctx.def, ctx.type.type_id, "form", box.value.trim()); + ctx.changed(); + }); +} + +/** 알약 색(style.color). */ +function colorCell(ctx: CellCtx): HTMLElement { + const box = el("input", { + className: "m02-sod__color", + attrs: { type: "color", "data-sod-color": "" }, + }) as HTMLInputElement; + const style = (ctx.type.style ?? {}) as { color?: string }; + box.value = style.color ?? "#888888"; + box.disabled = !ctx.edit; + box.addEventListener("change", () => { + ctx.type.style = { ...style, color: box.value }; + ctx.changed(); + }); + return box; +} + +const fixed = (text: string): HTMLElement => + el("span", { + className: "m02-sod__fixed", + children: [el("span", { text }), createTag(to("Fixed"), "neutral")], + }); + +/** 「종류별」 탭이 쓰는 반영 칸들 — [글, 칸] 짝. */ +export function reflectCells( + def: StructDef, + type: TypeDef, + edit: boolean, + changed: () => void, +): Array<[string, HTMLElement]> { + const row = rowsOf(def).get(type.type_id); + if (!row) return []; + const ctx = { def, type, row, edit, changed }; + const opts = shapeOptsCell(ctx); + bindForm(opts, ctx); + return [ + [to("ColList"), reflectCell(ctx, "목록")], + [to("ColPlacement"), fixed(row.placement)], + [to("ColOwner"), fixed(row.owner)], + [to("ColStation"), reflectCell(ctx, "횡단도")], + [to("ColTitle"), reflectCell(ctx, "카드이름")], + [to("ColShape"), reflectCell(ctx, "형상")], + [to("ColShapeOpts"), opts], + [to("ColColor"), colorCell(ctx)], + [to("Col3D"), reflectCell(ctx, "3D")], + ]; +} + +function moveButton(label: string, run: () => boolean, off: boolean, changed: () => void) { + const b = createButton({ label, variant: "ghost", size: "sm" }); + b.disabled = off; + b.addEventListener("click", () => run() && changed()); + return b; +} + +/** 그 종류 검사 글 — 이름 · 키가 든 줄. */ +const issuesOf = (issues: readonly string[], type: TypeDef): string[] => + issues.filter((t) => t.includes(`「${type.name}」`) || t.includes(`「${type.type_id}`)); + +export function buildTable(opt: { + def: StructDef; + edit: boolean; + changed: () => void; + issues: readonly string[]; +}): HTMLElement { + const { def, edit, changed, issues } = opt; + const heads = [ + "ColFamily", + "ColKind", + "ColName", + "ColList", + "ColPlacement", + "ColOwner", + "ColStation", + "ColTitle", + "ColShape", + "ColShapeOpts", + "ColColor", + "Col3D", + "ColCheck", + ] as const; + const td = (child: HTMLElement | string): HTMLElement => + el("td", typeof child === "string" ? { text: child } : { children: [child] }); + const body: HTMLElement[] = []; + const families = Object.keys(def.구조물군); + families.forEach((g, gi) => { + const name = textBox(def.구조물군[g], (v) => (def.구조물군[g] = v), changed, { + edit, + attr: "data-sod-family-name", + }); + const familyRow = el("tr", { + className: "m02-sod__familyrow", + attrs: { "data-sod-table-family": g }, + children: [ + el("td", { + attrs: { colspan: String(heads.length) }, + children: [ + el("strong", { text: g }), + name, + ...(edit + ? [ + moveButton(to("GroupUp"), () => stepFamily(def, g, -1), gi === 0, changed), + moveButton( + to("GroupDown"), + () => stepFamily(def, g, 1), + gi === families.length - 1, + changed, + ), + ] + : []), + ], + }), + ], + }); + body.push(familyRow); + const kinds = def.types.filter((t) => t.group === g); + kinds.forEach((type, ti) => { + const cells = new Map(reflectCells(def, type, edit, changed)); + const bad = issuesOf(issues, type); + const reset = createButton({ label: to("Reset"), variant: "ghost", size: "sm" }); + reset.disabled = !edit || !(def as { 반영?: Record }).반영?.[type.type_id]; + reset.addEventListener("click", () => (resetRow(def, type.type_id), changed())); + body.push( + el("tr", { + attrs: { "data-sod-table-kind": type.type_id }, + children: [ + td(""), + el("td", { + children: [ + el("small", { className: "m02-opts__key", text: type.type_id }), + ...(edit + ? [ + moveButton( + to("GroupUp"), + () => stepType(def, type.type_id, -1), + ti === 0, + changed, + ), + moveButton( + to("GroupDown"), + () => stepType(def, type.type_id, 1), + ti === kinds.length - 1, + changed, + ), + ] + : []), + ], + }), + td( + textBox(type.name, (v) => (type.name = v), changed, { + edit, + attr: "data-sod-kind-name", + }), + ), + ...[ + "ColList", + "ColPlacement", + "ColOwner", + "ColStation", + "ColTitle", + "ColShape", + "ColShapeOpts", + "ColColor", + "Col3D", + ].map((key) => td(cells.get(to(key as (typeof heads)[number])) ?? "")), + el("td", { + attrs: { "data-sod-table-check": "" }, + children: [ + bad.length + ? el("span", { className: "m02-sod__bad", text: bad.join(" · ") }) + : createTag(to("Ok"), "accent"), + reset, + ], + }), + ], + }), + ); + }); + }); + return el("div", { + className: "m02-sod__tablewrap", + children: [ + el("p", { className: "m02-side__empty", text: to("TableNote") }), + el("table", { + className: "m02-sod__table", + attrs: { "data-sod-table": "" }, + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: to(h) })) })], + }), + el("tbody", { children: body }), + ], + }), + ], + }); +} + +/** 「종류별」 탭 머리 아래 반영 칸 묶음(한눈 표 한 줄과 같은 칸). */ +export function buildReflectBlock( + def: StructDef, + type: TypeDef, + edit: boolean, + changed: () => void, +): HTMLElement { + return el("div", { + className: "m02-sod__block", + attrs: { "data-sod-reflect-block": "" }, + children: [ + el("h4", { className: "m02-opts__group", text: to("ReflectHead") }), + el("div", { + className: "m02-sod__reflect", + children: reflectCells(def, type, edit, changed).map(([label, cell]) => + el("label", { + className: "m02-sod__reflectrow", + children: [el("span", { text: label }), cell], + }), + ), + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 439e0daf0..4f475b329 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -18,10 +18,12 @@ from pydantic import ValidationError from B05_Profile import B05_Profile_Structures_Schema as schema from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors FILE = "구조물정의.json" HISTORY = "구조물정의_이력" -TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"} +# 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`) +TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"} SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list} SPOT = ("줄", "칸", "너비") @@ -171,8 +173,8 @@ def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s return errors -def check(doc: Any) -> list[str]: - """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과).""" +def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]: + """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2).""" from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions import M02_SO_Options as options @@ -194,7 +196,8 @@ def check(doc: Any) -> list[str]: fn(doc[section], plain) except store.StoreError as e: errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)] - return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) + errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) + return errors + ([] if errors else catalog_errors(doc, before)) def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]: @@ -206,7 +209,7 @@ def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> if str(version) != str(have): raise store.StoreError(409, {"stale": [FILE], "판": have}) doc = {**change(current), "판": have} - errors = check(doc) + errors = check(doc, current) if errors: raise store.StoreError(422, {"errors": errors}) atomic_write_json(history_path(have), current) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 233ecdc49..726c63bc9 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -368,3 +368,67 @@ font-size: var(--text-caption); line-height: 1; } + +/* ── 322-2 탭 셋 · 한눈 표 · 부품 ── */ +.m02-sod__tabs { + display: flex; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} +.m02-sod__tablewrap { + overflow-x: auto; +} +.m02-sod__table { + border-collapse: collapse; + font-size: var(--text-body-sm); + min-width: 100%; +} +.m02-sod__table th, +.m02-sod__table td { + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-4); + text-align: left; + vertical-align: middle; + white-space: nowrap; +} +.m02-sod__familyrow td { + background: var(--color-surface-raised); +} +.m02-sod__table tr.is-off { + opacity: 0.5; +} +.m02-sod__cell { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); +} +.m02-sod__cell.is-default { + opacity: 0.6; +} +.m02-sod__cell.is-changed { + font-weight: 600; +} +.m02-sod__fixed { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + opacity: 0.7; +} +.m02-sod__color { + width: 32px; + height: 22px; + padding: 0; + border: none; + background: none; +} +.m02-sod__reflect { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-4); +} +.m02-sod__reflectrow { + display: grid; + grid-template-columns: 7em 1fr; + align-items: center; + gap: var(--spacing-4); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index d56251b7b..7ebfdd906 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -125,6 +125,48 @@ const TEXT = { Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"], Detail: ["B06 전용", "B06 only"], Save: ["저장", "Save"], + TabTable: ["한눈 표", "Overview"], + TabKinds: ["종류별", "By type"], + TabParts: ["부품", "Parts"], + TableNote: [ + "종류마다 B05 · B06 반영 한 줄 — 목록 · 횡단도 · 카드 이름 · 형상 · 3D 는 코드 부품 중에서 고름 · 흐린 칸 = 처음 값(기본) · 진한 칸 = 바꾼 값 · 자리꼴 · 정본은 만든 뒤 고정", + "One row per type — pick parts for list · cross section · card name · shape · 3D; faded = default, bold = changed", + ], + ColFamily: ["군", "Family"], + ColKind: ["종류", "Type"], + ColName: ["이름", "Name"], + ColList: ["목록", "List"], + ColPlacement: ["자리꼴", "Placement"], + ColOwner: ["정본", "Owner"], + ColStation: ["횡단도", "Cross section"], + ColTitle: ["카드 이름", "Card name"], + ColShape: ["형상", "Shape"], + ColShapeOpts: ["형상칸", "Shape fields"], + ColColor: ["알약 색", "Pill color"], + Col3D: ["3D", "3D"], + ColCheck: ["검사", "Check"], + ListBoth: ["B05 · B06", "B05 · B06"], + ListB06: ["B06 만", "B06 only"], + ListHidden: ["안 보임", "Hidden"], + OffNone: ["없음", "None"], + TitleName: ["종류 이름", "Type name"], + Default: ["기본", "Default"], + Fixed: ["고정", "Fixed"], + Reset: ["처음 값으로", "Reset"], + Ok: ["통과", "OK"], + WallForm: ["기본 형태", "Default form"], + Weep: ["물빼기", "Weep holes"], + ReflectHead: ["B05 · B06 반영", "B05 · B06 use"], + ShownCol: ["보임", "Shown"], + CannotHide: ["못 숨김", "Required"], + PartsNote: [ + "코드가 그리는 방법(부품) — 끄면 그 부품을 쓰는 종류는 그 갈래가 없음으로 · 읽는 칸 = 그 부품이 쓰는 옵션(숨기지 못함)", + "Code drawing parts — turning a part off makes its users fall back to none; read fields cannot be hidden", + ], + PartRead: ["읽는 칸", "Reads"], + PartFits: ["맞는 자리꼴 · 정본", "Fits"], + PartUsers: ["쓰는 종류", "Used by"], + PartOn: ["켬", "On"], Stale: [ "그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요", "Changed meanwhile — reloaded, please edit again", diff --git a/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs index 39c462d16..ec272f756 100644 --- a/resources/tester/helper_322_0_golden.cjs +++ b/resources/tester/helper_322_0_golden.cjs @@ -223,6 +223,7 @@ const { fetchStructureTypes, onStaleStructureTypes, pipesToStructureMarks, + staleDefinitionNotice, writePendingStructures, } = loadTs(B05("B05_Profile_Api_Structures")); const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); @@ -322,6 +323,14 @@ async function main() { await fetchStructureTypes(true); const staleCalls = server.typeCalls - sameCalls; off(); + // 판 알림(322-2) — 프로젝트 정본이 옛 판으로 적혔으면 한 번만 · 같은 판 · 판 없음은 안 알림 + const old = server.version - 1; + out.notice = [ + staleDefinitionNotice(PROJECT, old), + staleDefinitionNotice(PROJECT, old), + staleDefinitionNotice(PROJECT, server.version), + staleDefinitionNotice(PROJECT, null), + ]; out.version = { same, sameCalls, diff --git a/resources/tester/helper_322_2_check.cjs b/resources/tester/helper_322_2_check.cjs new file mode 100644 index 000000000..710dcf345 --- /dev/null +++ b/resources/tester/helper_322_2_check.cjs @@ -0,0 +1,109 @@ +/* PLAN 322-2 구조물 표 검사 · 편집 헬퍼 — 진짜 `M02_SO_Def_Check.ts` · `M02_SO_Def_Reflect.ts` · + * `design_structure_spec.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = argv[2] JSON `{cases: {이름: [정의, 앞 판|null]}, definition}`. 짝 = `test_322_2_check_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const SO = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`); +const check = load(SO("M02_SO_Def_Check")); +const reflect = load(SO("M02_SO_Def_Reflect")); +const spec = load(path.join(ROOT, "A00_Common", "design", "design_structure_spec.ts")); +const { STRUCTURE_SPECS } = load( + path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"), +); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const out = { cases: {} }; + +for (const [name, [doc, before]] of Object.entries(input.cases)) + out.cases[name] = check.catalogProblems(doc, before); + +// 반영 손질 — 목록 + enabled 짝 · 처음 값이면 지움 · 형상칸 · 보임 · 부품 쓰는 종류 +const def = clone(input.definition); +const enabled = (id) => def.types.find((t) => t.type_id === id).enabled; +const own = (id) => clone(reflect.reflectOf(def)[id] ?? null); +reflect.setReflect(def, "wildlife_path", "목록", "both"); +reflect.setReflect(def, "retaining_wall", "목록", "b06"); +reflect.setReflect(def, "open_ditch", "횡단도", "none"); +reflect.setReflect(def, "cross_drain_exposed", "횡단도", "anchor"); +const setBefore = reflect.isSet(def, "cross_drain_exposed", "횡단도"); +reflect.setShapeOpt(def, "soil_guard", "weep", true); +const weepOn = own("soil_guard"); +reflect.setShapeOpt(def, "soil_guard", "weep", false); +reflect.setShown(def, "pipe", ["inlet_type", "없는키"], false); +out.reflect = { + wildlife: [own("wildlife_path"), enabled("wildlife_path")], + wall: [own("retaining_wall"), enabled("retaining_wall")], + openDitch: own("open_ditch"), + exposed: [own("cross_drain_exposed"), setBefore], + weepOn, + weepOff: own("soil_guard"), + shown: clone(def.표시.pipe), + readPipe: [...reflect.readKeys(def, "pipe")].sort(), + wallUsers: reflect.usersOf(def, "형상", "wall").map((row) => row.typeId), +}; +reflect.resetRow(def, "cross_drain_exposed"); +reflect.resetRow(def, "retaining_wall"); +out.reflect.reset = [own("cross_drain_exposed"), own("retaining_wall"), enabled("retaining_wall")]; +// 저장 전 목록 + enabled 함께 — 목록은 그대로 · enabled = both 일 때만 참 +const plain = clone(input.definition); +const lists = (d) => [...reflect.rowsOf(d).values()].map((row) => `${row.typeId}:${row.list}`); +const listBefore = lists(plain); +reflect.syncEnabled(plain); +out.sync = { + same: JSON.stringify(lists(plain)) === JSON.stringify(listBefore), + enabled: plain.types.filter((t) => t.enabled).map((t) => t.type_id), +}; +reflect.setPartOn(plain, "형상.wall", false); +out.partOff = [clone(plain.부품), reflect.rowsOf(plain).get("retaining_wall").shape]; +reflect.setPartOn(plain, "형상.wall", true); +out.partOn = clone(plain.부품); + +// 명세 폼 숨김 — 저장 · 갈래 키가 모두 숨김인 칸 · 키 있는 칸이 모두 숨긴 상자 +out.spec = {}; +for (const [tid, sp] of Object.entries(STRUCTURE_SPECS)) { + const keys = sp.containers.flatMap((c) => c.items.flatMap((i) => spec.specItemKeys(i))); + const hidden = new Set(keys.slice(0, 1)); + const all = new Set(keys); + out.spec[tid] = { + first: keys[0] ?? null, + hiddenItems: sp.containers.flatMap((c) => + c.items.filter((i) => spec.specItemHidden(i, hidden)).map((i) => `${c.id}.${i.id}`), + ), + allBoxes: sp.containers.filter((c) => spec.specContainerHidden(c, all)).map((c) => c.id), + keyedBoxes: sp.containers + .filter((c) => c.items.some((i) => spec.specItemKeys(i).length)) + .map((c) => c.id), + noneHidden: sp.containers.filter((c) => spec.specContainerHidden(c, new Set())).length, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py index 69948eccf..9b5cabd9e 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -182,7 +182,7 @@ def _run(work: Path) -> dict: for index, sample in enumerate(samples): key = f"{sample['type_id']}/{sample['case']}" rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)} - return {"samples": rows, "pick": ts["pick"], "version": ts["version"]} + return {"samples": rows, "pick": ts["pick"], "version": ts["version"], "notice": ts["notice"]} def test_type_cache_follows_definition_version(result: dict) -> None: @@ -196,6 +196,17 @@ def test_type_cache_follows_definition_version(result: dict) -> None: } +def test_stale_definition_notice_once(result: dict) -> None: + """322-2 판 알림 — structures.json 판 ≠ 지금 판이면 한 줄 · 탭 수명 동안 한 번 · 같은 판 · 판 없음은 없음.""" + now = load_definition()["판"] + 1 # 시험 서버가 판을 하나 올린 뒤 + assert result["notice"] == [ + f"구조물 정의가 판 {now} 으로 바뀜 — 저장하면 반영", + None, + None, + None, + ] + + def _cells(table: dict) -> dict[tuple[str, str, str], object]: return { (part, key, column): value diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py new file mode 100644 index 000000000..d3557175c --- /dev/null +++ b/resources/tester/test_322_2_check_mirror.py @@ -0,0 +1,238 @@ +"""PLAN 322-2 구조물 표 화면 — 검사 짝(화면 = 서버) · 반영 손질 · [저장] 길 · 명세 폼 숨김. + +- 거울: 검사 견본마다 TS `catalogProblems` = 파이썬 `catalog_errors`(글까지 같음). +- 1-3 규칙: 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 부품이 읽는 칸 새로 숨김 금지 · + 만든 뒤 고정(자리꼴 · 정본 · 군 글자 · 종류 지우기 · 선택지 값). +- 반영 손질: 처음 값이면 반영에서 지움 · 목록 + enabled(both 일 때만 참) · 형상칸 · 보임 · 부품 켬끔. +- 서버 [저장]: 반영 · 부품 · 알약 색 · enabled 를 받아 판 +1 · 표(`catalog()`)가 바로 따름 · 틀리면 422. +- 명세 폼 숨김: 칸 = 쓰는 옵션 키가 모두 숨김 · 상자 = 키 있는 칸이 모두 숨김(지금 정의로는 숨는 칸 없음). +TS 쪽 = `helper_322_2_check.cjs`(Node) · 마스터 정의는 읽기만(사본으로 고침). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +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 common_util.common_util_structure_catalog import catalog +from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent +URL = "/api/m02/structure-definition" +ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _real() -> dict: + return json.loads(Path(schema.DEFINITION_PATH).read_text(encoding="utf-8")) + + +def _type(doc: dict, type_id: str) -> dict: + return next(t for t in doc["types"] if t["type_id"] == type_id) + + +def _cases() -> dict[str, tuple[dict, dict | None]]: + real = _real() + cases: dict[str, tuple[dict, dict | None]] = {"real": (real, real)} + bad = copy.deepcopy(real) + bad["반영"] = { + "nope": {}, + "pipe": { + "모름": 1, + "목록": "x", + "횡단도": "zz", + "형상": 3, + "형상칸": {"form": " "}, + "3D": "none", + }, + } + bad["부품"] = {"형상.zz": False, "횡단도.span3": "no", "끔": False} + cases["shape_bad"] = (bad, real) + rule = copy.deepcopy(real) + rule["반영"] = {"open_ditch": {"횡단도": "anchor"}} + cases["rule218"] = (rule, real) + mismatch = copy.deepcopy(real) + mismatch["반영"] = {"retaining_wall": {"형상": "pipe_set"}, "berm": {"횡단도": "anchor"}} + cases["mismatch"] = (mismatch, real) + hide = copy.deepcopy(real) + hide["표시"]["pipe"]["revet_foundation"] = False # 관 세트가 읽는 기본값 없는 칸 새로 숨김 + hide["표시"]["retaining_wall"]["height_m"] = False # 기본값 있는 칸 = 숨겨도 됨(늘 기본값) + cases["hide_read"] = (hide, real) + cases["hide_no_before"] = (hide, None) + fixed = copy.deepcopy(real) + _type(fixed, "box_culvert")["placement"] = "interval" + _type(fixed, "revetment")["managed_by"] = None + _type(fixed, "mirror")["group"] = "F" + dia = next(o for o in _type(fixed, "pipe")["options"] if o["key"] == "pipe_diameter_mm") + dia["choices"] = [*dia["choices"], "2000"] + fixed["types"] = [t for t in fixed["types"] if t["type_id"] != "etc"] + cases["fixed"] = (fixed, real) + off = copy.deepcopy(real) + off["부품"] = {"형상.wall": False} + cases["parts_off"] = (off, real) + return cases + + +@pytest.fixture(scope="module") +def ts() -> dict: + payload = {"cases": _cases(), "definition": _real()} + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_2_check.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_check_mirror_screen_equals_server(ts: dict) -> None: + for name, (doc, before) in _cases().items(): + assert catalog_errors(doc, before) == ts["cases"][name], name + + +def test_check_rules(ts: dict) -> None: + got = ts["cases"] + assert got["real"] == [] + assert got["shape_bad"] == [ + "반영 「nope」 — 없는 종류이거나 묶음 아님", + "반영 「pipe.모름」 — 모르는 칸", + "반영 「pipe.목록」 — both · b06 · hidden 중 하나", + "반영 「pipe.횡단도」 — 없는 부품 「zz」", + "반영 「pipe.형상」 — 없는 부품 「3」", + "반영 「pipe.형상칸」 — form 글 · weep 참거짓", + "부품 「형상.zz」 — 없는 부품", + "부품 「횡단도.span3」 — 참거짓", + "부품 「끔」 — 없는 부품", + ] + assert got["rule218"] == ["「개거(겉도랑)」 형상 none — 횡단도도 none 이어야 함"] + assert got["mismatch"] == [ + "「옹벽」 형상 「pipe_set」 — 자리꼴 interval · 정본 structures 에 안 맞음" + ] + assert got["hide_read"] == [ + "「배관」 형상 「pipe_set」 이 읽는 칸 「revet_foundation」 — 숨길 수 없음" + ] + assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 + assert got["fixed"] == [ + # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 + "「BOX암거」 형상 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", + "「BOX암거」 3D 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", + "「기슭막이」 형상 「hidden_revet」 — 자리꼴 interval · 정본 structures 에 안 맞음", + "「pipe.pipe_diameter_mm」 선택지 값 — 고칠 수 없음(이름은 이름 표로)", + "「box_culvert」 자리꼴 — 만든 뒤 고정", + "「revetment」 정본 — 만든 뒤 고정", + "「mirror」 군 글자 — 만든 뒤 고정", + "종류 「etc」 — 지울 수 없음", + ] + # 형상 부품을 끄면 벽 다섯은 형상 none — 횡단도도 none 으로 바꿔야 저장됨 + assert len(got["parts_off"]) == 5 and all("횡단도도 none" in t for t in got["parts_off"]) + + +def test_reflect_edits(ts: dict) -> None: + r = ts["reflect"] + assert r["wildlife"] == [None, True] # b06 → both = enabled 참이 곧 처음 값 짝(반영 안 남음) + assert r["wall"] == [None, False] # both → b06 = enabled 거짓 + assert r["openDitch"] is None # 처음 값(none)과 같음 — 반영 안 남음 + assert r["exposed"] == [{"횡단도": "anchor"}, True] + assert r["weepOn"] == {"형상칸": {"weep": True}} and r["weepOff"] is None + assert r["shown"] == {"pipe_kind": False, "inlet_type": False} # 정의 옵션만 + assert r["readPipe"] == ["inlet_basin_height_m", "revet_foundation"] # 기본값 없는 읽는 칸 + assert r["wallUsers"] == [ + "retaining_wall", + "masonry_wet", + "masonry_dry", + "soil_guard", + "boulder_masonry", + ] + assert r["reset"] == [None, None, True] + + +def test_save_writes_list_and_enabled_together(ts: dict) -> None: + sync = ts["sync"] + assert sync["same"] is True + rows = catalog() + assert set(sync["enabled"]) == {t for t, row in rows.items() if row["list"] == "both"} + assert len(sync["enabled"]) == 23 # B05 목록 23(브레인 화면 확인) + assert ts["partOff"] == [{"형상.wall": False}, "none"] and ts["partOn"] == {} + + +def test_spec_form_hidden_rule(ts: dict) -> None: + spec = ts["spec"] + assert spec["pipe"]["hiddenItems"] == ["pipe.diameter"] + assert spec["box_culvert"]["allBoxes"] == spec["box_culvert"]["keyedBoxes"] + assert all(s["noneHidden"] == 0 for s in spec.values()) + assert spec["etc"]["keyedBoxes"] == [] # 선택 상자 인스턴스 키는 옵션 밖 — 숨김 대상 아님 + + +@pytest.fixture +def copy_def(tmp_path: Path, monkeypatch: pytest.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() -> TestClient: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: ADMIN + return TestClient(app) + + +def test_server_save_reflect_parts_color_enabled(copy_def: Path) -> None: + client = _client() + doc = client.get(URL).json() + sent = copy.deepcopy(doc) + sent["반영"] = {"open_ditch": {"횡단도": "anchor", "형상": "underdrain", "목록": "b06"}} + sent["부품"] = {"3D.chute": False} + _type(sent, "mirror")["style"]["color"] = "#123456" + _type(sent, "masonry_wet")["enabled"] = False # 목록 hidden ⇒ enabled 거짓(화면이 함께 씀) + res = client.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 200, res.text + got = res.json() + assert got["판"] == doc["판"] + 1 + assert got["반영"] == sent["반영"] and got["부품"] == sent["부품"] + rows = catalog() # 파일 시각으로 바로 새 표 + assert (rows["open_ditch"]["station"], rows["open_ditch"]["list"]) == ("anchor", "b06") + assert rows["ford_pavement"]["model3d"] == "none" + assert rows["mirror"]["color"] == "#123456" + assert rows["masonry_wet"]["list"] == "hidden" + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d.update(반영={"open_ditch": {"횡단도": "anchor"}}), # 218 + lambda d: d["표시"].setdefault("pipe", {}).update(revet_foundation=False), # 못 숨김 칸 + lambda d: next(o for o in _type(d, "pipe")["options"] if o["key"] == "pipe_kind")[ + "choices" + ].append("새관"), # 선택지 값 + lambda d: _type(d, "berm").update(placement="point"), # 자리꼴 고정 + ], +) +def test_server_rejects_rule_breaks(copy_def: Path, change) -> None: + client = _client() + doc = client.get(URL).json() + sent = copy.deepcopy(doc) + change(sent) + res = client.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 422, res.text + assert json.loads(copy_def.read_text("utf-8"))["판"] == doc["판"] diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py index c344372c5..6ca5e6242 100644 --- a/resources/tester/test_m02_structure_def_screen.py +++ b/resources/tester/test_m02_structure_def_screen.py @@ -47,7 +47,8 @@ def _screen_edit(doc: dict) -> dict: 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"]] + # 선택지 값은 만든 뒤 고정(322-2) — 이름은 이름 표(`종류.pipe.이름`)로 + doc["종류"].setdefault("pipe", {}).setdefault("이름", {})["pipe.kind=흄관"] = "흄관(고침)" pipe["options"].append( { "key": "opt_ab12cd", @@ -62,7 +63,7 @@ def _screen_edit(doc: dict) -> dict: 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"] = {"wing_wall_type": False} # 부품이 안 읽는 칸(322-2 읽는 칸은 못 숨김) doc["그룹"]["pipe"][1]["형상"] = { "길이": {"받기": True, "뜻": "유입 보호공 연장"}, "높이": {"받기": False, "뜻": ""}, @@ -70,7 +71,7 @@ def _screen_edit(doc: dict) -> dict: doc["규칙"] = [ { "종류": "pipe", - "만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]}, + "만약": {"옵션": "pipe_kind", "값들": ["흄관"]}, "그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"}, }, {