Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1
This commit is contained in:
@@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
|
||||
const extras =
|
||||
layer === "system"
|
||||
? {
|
||||
옵션: section(options.title, options.body),
|
||||
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
|
||||
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
}
|
||||
: null;
|
||||
@@ -151,7 +151,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
...(extras ? [extras.옵션, extras.도면] : []),
|
||||
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
});
|
||||
@@ -308,7 +308,9 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면"): void => {
|
||||
extras?.[target].classList.remove("is-collapsed");
|
||||
const sec = extras?.[target];
|
||||
if (!sec) return; // 옵션 보이기는 시스템 관리자만
|
||||
sec.classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Api.ts
|
||||
* 구조물 옵션 보이기 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물옵션표시.json`)
|
||||
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
|
||||
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "../M02_MasterTemplete_Api_Fetch";
|
||||
@@ -27,3 +28,29 @@ export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
|
||||
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
|
||||
|
||||
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
|
||||
export interface OptionGroupDef {
|
||||
id: string;
|
||||
이름: string;
|
||||
옵션: string[];
|
||||
}
|
||||
|
||||
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
|
||||
export interface GroupsDoc {
|
||||
판: string;
|
||||
그룹: Record<string, OptionGroupDef[]>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups");
|
||||
|
||||
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptionGroups = (
|
||||
판: string,
|
||||
그룹: GroupsDoc["그룹"],
|
||||
): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
|
||||
|
||||
정본 = `resources/master_template/구조물옵션그룹.json` =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
|
||||
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
|
||||
|
||||
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
|
||||
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
|
||||
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물옵션그룹.json"
|
||||
GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
|
||||
NAME_MAX = 40
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 groups_of() -> dict[str, list[dict[str, Any]]]:
|
||||
"""종류 → `[{id, name, options}]` — 폼 응답용 · 깨진 표 · 모르는 종류는 빼고 로그만."""
|
||||
try:
|
||||
doc = _load()[0]
|
||||
except store.StoreError as e:
|
||||
logger.warning("구조물 옵션 그룹 표를 못 읽어 그룹 없음으로 봄 — %s", e.detail)
|
||||
return {}
|
||||
known = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for type_id, rows in doc.items():
|
||||
if type_id not in known or not isinstance(rows, list):
|
||||
continue
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not GROUP_ID.match(str(row.get("id", ""))):
|
||||
continue
|
||||
keys = [k for k in row.get("옵션") or [] if k in known[type_id]]
|
||||
out.setdefault(type_id, []).append(
|
||||
{"id": row["id"], "name": str(row.get("이름") or row["id"]), "options": keys}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def listing() -> dict[str, Any]:
|
||||
"""`{판, 그룹, 종류}` — 종류 = 레지스트리 전부(옵션 없는 종류도 · 빈 그룹을 둘 수 있음)."""
|
||||
doc, version = _load()
|
||||
kinds = [
|
||||
{
|
||||
"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
|
||||
],
|
||||
}
|
||||
for t in load_structure_types()
|
||||
]
|
||||
return {"판": version, "그룹": doc, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
|
||||
if not isinstance(groups, 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, list[dict[str, Any]]] = {}
|
||||
for type_id, rows in groups.items():
|
||||
if type_id not in keys:
|
||||
errors.append(f"없는 구조물 종류 「{type_id}」")
|
||||
continue
|
||||
if not isinstance(rows, list):
|
||||
errors.append(f"「{type_id}」 는 그룹 목록이어야 함")
|
||||
continue
|
||||
ids: set[str] = set()
|
||||
names: set[str] = set()
|
||||
owner: dict[str, str] = {}
|
||||
clean: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"「{type_id}」 그룹은 묶음이어야 함")
|
||||
continue
|
||||
gid = str(row.get("id", ""))
|
||||
name = str(row.get("이름", "")).strip()
|
||||
opts = row.get("옵션") or []
|
||||
if not GROUP_ID.match(gid) or gid in ids:
|
||||
errors.append(f"「{type_id}」 그룹 id 「{gid}」 가 틀리거나 겹침")
|
||||
if not name or len(name) > NAME_MAX or name in names:
|
||||
errors.append(
|
||||
f"「{type_id}」 그룹 이름 「{name}」 은 1~{NAME_MAX}자 · 겹치면 안 됨"
|
||||
)
|
||||
if not isinstance(opts, list):
|
||||
errors.append(f"「{type_id}.{name}」 옵션은 목록이어야 함")
|
||||
opts = []
|
||||
for key in opts:
|
||||
if key not in keys[type_id]:
|
||||
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
|
||||
elif key in owner:
|
||||
errors.append(
|
||||
f"「{type_id}.{key}」 는 「{owner[key]}」 · 「{name}」 두 그룹에 있음"
|
||||
)
|
||||
else:
|
||||
owner[key] = name
|
||||
ids.add(gid)
|
||||
names.add(name)
|
||||
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
|
||||
if clean:
|
||||
out[type_id] = clean
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return out
|
||||
|
||||
|
||||
def save(groups: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(groups)
|
||||
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()
|
||||
@@ -0,0 +1,141 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Groups.ts
|
||||
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
|
||||
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
||||
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
|
||||
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { OptionGroupDef } from "./M02_SO_Api";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
const NAME_MAX = 40;
|
||||
|
||||
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
|
||||
export function newGroupId(list: readonly OptionGroupDef[]): string {
|
||||
const taken = new Set(list.map((g) => g.id));
|
||||
for (;;) {
|
||||
const id = `g${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (/^g[0-9a-z]{1,16}$/.test(id) && !taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
|
||||
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
|
||||
return list.find((g) => g.옵션.includes(key))?.id ?? null;
|
||||
}
|
||||
|
||||
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
|
||||
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
|
||||
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
|
||||
const target = gid ? list.find((g) => g.id === gid) : null;
|
||||
if (target) target.옵션.push(key);
|
||||
}
|
||||
|
||||
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
|
||||
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
|
||||
for (const list of groups.values()) {
|
||||
const names = list.map((g) => g.이름.trim());
|
||||
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
|
||||
if (new Set(names).size !== names.length) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface GroupBlockOptions {
|
||||
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
|
||||
list: OptionGroupDef[];
|
||||
edit: boolean;
|
||||
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
|
||||
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
const { list } = opt;
|
||||
const rows = list.map((group, index) => {
|
||||
const name = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sog__name",
|
||||
attrs: { type: "text", maxlength: String(NAME_MAX), "data-sog-name": group.id },
|
||||
}) as HTMLInputElement;
|
||||
name.value = group.이름;
|
||||
name.placeholder = to("GroupName");
|
||||
name.disabled = !opt.edit;
|
||||
// 글자마다 사본만 고침(다시 그리면 초점이 끊김) · 칸을 떠날 때 다시 그림(선택지 이름)
|
||||
name.addEventListener("input", () => {
|
||||
group.이름 = name.value;
|
||||
name.classList.toggle("is-invalid", !name.value.trim());
|
||||
});
|
||||
name.addEventListener("change", () => opt.changed());
|
||||
const step = (delta: number): void => {
|
||||
const at = index + delta;
|
||||
if (at < 0 || at >= list.length) return;
|
||||
[list[index], list[at]] = [list[at], list[index]];
|
||||
opt.changed();
|
||||
};
|
||||
const up = createButton({ label: to("GroupUp"), variant: "ghost", size: "sm" });
|
||||
const down = createButton({ label: to("GroupDown"), variant: "ghost", size: "sm" });
|
||||
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
|
||||
up.disabled = !opt.edit || index === 0;
|
||||
down.disabled = !opt.edit || index === list.length - 1;
|
||||
del.disabled = !opt.edit;
|
||||
up.addEventListener("click", () => step(-1));
|
||||
down.addEventListener("click", () => step(1));
|
||||
del.addEventListener("click", async () => {
|
||||
if (!(await showConfirmDialog(to("GroupDelAsk", group.이름), to("GroupDel")))) return;
|
||||
list.splice(list.indexOf(group), 1);
|
||||
opt.changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sog__row",
|
||||
attrs: { "data-sog-group": group.id },
|
||||
children: [
|
||||
name,
|
||||
el("small", {
|
||||
className: "m02-opts__key",
|
||||
text: to("GroupCount", String(group.옵션.length)),
|
||||
}),
|
||||
...(opt.edit ? [up, down, del] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sog-add", "");
|
||||
add.addEventListener("click", () => {
|
||||
let n = list.length + 1;
|
||||
while (list.some((g) => g.이름 === to("GroupNew", String(n)))) n += 1;
|
||||
list.push({ id: newGroupId(list), 이름: to("GroupNew", String(n)), 옵션: [] });
|
||||
opt.changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sog",
|
||||
children: [
|
||||
el("h4", { className: "m02-opts__group", text: to("GroupHead") }),
|
||||
el("p", { className: "m02-side__empty", text: to("GroupNote") }),
|
||||
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("GroupNone") })]),
|
||||
...(opt.edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
|
||||
export function buildPlaceSelect(
|
||||
list: OptionGroupDef[],
|
||||
key: string,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLSelectElement {
|
||||
const select = el("select", {
|
||||
className: "ui-input ui-input--sm m02-sog__place",
|
||||
attrs: { "data-sog-place": key },
|
||||
}) as HTMLSelectElement;
|
||||
select.append(new Option(to("Main"), ""), ...list.map((g) => new Option(g.이름 || g.id, g.id)));
|
||||
select.value = groupOf(list, key) ?? "";
|
||||
select.disabled = !edit || !list.length;
|
||||
select.addEventListener("change", () => {
|
||||
moveOption(list, key, select.value || null);
|
||||
changed();
|
||||
});
|
||||
return select;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
)
|
||||
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_Groups import groups_of
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,12 +36,21 @@ class OptionWithVisible(StructureOptionField):
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class OptionGroup(BaseModel):
|
||||
"""옵션 그룹 한 개(PLAN 46-2 · `M02_SO_Groups`) — 폼의 보조 컨테이너 하나 · 빈 옵션도 됨."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
options: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TypeWithVisible(StructureType):
|
||||
options: list[OptionWithVisible] = Field(default_factory=list)
|
||||
groups: list[OptionGroup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TypesWithVisible(StructureTypesResponse):
|
||||
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
|
||||
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸 · 종류마다 `groups`."""
|
||||
|
||||
types: list[TypeWithVisible]
|
||||
|
||||
@@ -86,12 +96,14 @@ def with_visible() -> TypesWithVisible:
|
||||
except store.StoreError as e:
|
||||
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
|
||||
doc = {}
|
||||
groups = groups_of()
|
||||
types = [
|
||||
{
|
||||
**t.model_dump(),
|
||||
"options": [
|
||||
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
|
||||
],
|
||||
"groups": groups.get(t.type_id, []),
|
||||
}
|
||||
for t in load_structure_types()
|
||||
]
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
|
||||
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_SO_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
|
||||
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
|
||||
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureOptionGroups,
|
||||
fetchStructureOptions,
|
||||
saveStructureOptionGroups,
|
||||
saveStructureOptions,
|
||||
type GroupsDoc,
|
||||
type OptionGroupDef,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_SO_Api";
|
||||
import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups";
|
||||
import "./M02_SO_Style.css";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
@@ -55,17 +61,19 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
let doc: OptionsDoc;
|
||||
let groups: GroupsDoc;
|
||||
try {
|
||||
doc = await fetchStructureOptions();
|
||||
[doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
draw(doc);
|
||||
draw(doc, groups);
|
||||
}
|
||||
|
||||
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc): void {
|
||||
/** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void {
|
||||
let version = doc.판;
|
||||
let groupVersion = groupsDoc.판;
|
||||
const edit = opt.isAdmin;
|
||||
/** 숨긴 옵션 — 종류 → 키 집합 */
|
||||
const hidden = new Map<string, Set<string>>();
|
||||
@@ -73,13 +81,31 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
hidden.clear();
|
||||
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
|
||||
};
|
||||
/** 그룹 — 종류 → 그룹[](고칠 사본) */
|
||||
const groups = new Map<string, OptionGroupDef[]>();
|
||||
const loadGroups = (src: GroupsDoc["그룹"]): void => {
|
||||
groups.clear();
|
||||
for (const [id, list] of Object.entries(src))
|
||||
groups.set(
|
||||
id,
|
||||
list.map((g) => ({ ...g, 옵션: [...g.옵션] })),
|
||||
);
|
||||
};
|
||||
load(doc.표시);
|
||||
let kinds = doc.종류;
|
||||
loadGroups(groupsDoc.그룹);
|
||||
/** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */
|
||||
let kinds = groupsDoc.종류;
|
||||
let current: OptionKind | null = null;
|
||||
let shownDirty = false;
|
||||
let groupDirty = false;
|
||||
|
||||
const kindList = el("div", { className: "m02-opts__kinds" });
|
||||
const optionList = el("div", { className: "m02-opts__list" });
|
||||
const off = (id: string): number => hidden.get(id)?.size ?? 0;
|
||||
const groupList = (id: string): OptionGroupDef[] => {
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
return groups.get(id)!;
|
||||
};
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const parts: HTMLElement[] = [];
|
||||
@@ -95,11 +121,13 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
);
|
||||
}
|
||||
const n = off(k.type_id);
|
||||
const g = groups.get(k.type_id)?.length ?? 0;
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-options-kind": k.type_id },
|
||||
children: [
|
||||
el("span", { text: k.name }),
|
||||
...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []),
|
||||
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
|
||||
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
|
||||
],
|
||||
@@ -114,45 +142,66 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
/** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */
|
||||
const groupsChanged = (): void => {
|
||||
groupDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
};
|
||||
|
||||
const paintOptions = (): void => {
|
||||
if (!current) {
|
||||
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const kind = current;
|
||||
const list = groupList(kind.type_id);
|
||||
const rows = kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = !hidden.get(kind.type_id)?.has(o.key);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
const set = hidden.get(kind.type_id) ?? new Set<string>();
|
||||
if (box.checked) set.delete(o.key);
|
||||
else set.add(o.key);
|
||||
hidden.set(kind.type_id, set);
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
});
|
||||
const tags: HTMLElement[] = [];
|
||||
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
|
||||
if (o.required && o.phase === "b05") {
|
||||
const tag = createTag(to("Required"), "danger");
|
||||
tag.title = to("RequiredTip");
|
||||
tags.push(tag);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-opts__option",
|
||||
children: [
|
||||
el("label", {
|
||||
className: "m02-opts__check",
|
||||
children: [
|
||||
box,
|
||||
el("span", { text: o.label }),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...tags,
|
||||
],
|
||||
}),
|
||||
buildPlaceSelect(list, o.key, edit, groupsChanged),
|
||||
],
|
||||
});
|
||||
});
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-options__head", text: kind.name }),
|
||||
...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<string>();
|
||||
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,
|
||||
],
|
||||
});
|
||||
}),
|
||||
buildGroupBlock({ list, edit, changed: groupsChanged }),
|
||||
el("h4", { className: "m02-opts__group", text: to("OptionHead") }),
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -161,38 +210,64 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
all.addEventListener("click", () => {
|
||||
if (![...hidden.values()].some((s) => s.size)) return;
|
||||
hidden.clear();
|
||||
setDirty(true);
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
|
||||
/** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
const [fresh, freshGroups] = await Promise.all([
|
||||
fetchStructureOptions(),
|
||||
fetchStructureOptionGroups(),
|
||||
]);
|
||||
version = fresh.판;
|
||||
groupVersion = freshGroups.판;
|
||||
kinds = freshGroups.종류;
|
||||
load(fresh.표시);
|
||||
loadGroups(freshGroups.그룹);
|
||||
shownDirty = groupDirty = false;
|
||||
sync();
|
||||
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error");
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
setDirty(false);
|
||||
if (shownDirty) {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
shownDirty = false;
|
||||
}
|
||||
if (groupDirty) {
|
||||
const 그룹: GroupsDoc["그룹"] = {};
|
||||
for (const [id, list] of groups)
|
||||
if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() }));
|
||||
const done = await saveStructureOptionGroups(groupVersion, 그룹);
|
||||
groupVersion = done.판;
|
||||
loadGroups(done.그룹);
|
||||
groupDirty = false;
|
||||
}
|
||||
sync();
|
||||
showToast(to("Saved"), "success");
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
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");
|
||||
}
|
||||
await reload();
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
@@ -211,6 +286,7 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
const sync = (): void => setDirty(shownDirty || groupDirty);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
opt.onOpen();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 구조물 옵션 보이기 길 — 정본 `M02_SO_Options.py`.
|
||||
"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`.
|
||||
|
||||
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -25,6 +26,11 @@ class OptionsBody(BaseModel):
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
|
||||
|
||||
class GroupsBody(BaseModel):
|
||||
판: str = "" # 그룹 표 판 — 어긋나면 409
|
||||
그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]}
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
with store.at(store.FOLDER):
|
||||
try:
|
||||
@@ -45,6 +51,18 @@ def put_structure_options(body: OptionsBody) -> dict:
|
||||
return _call(options.save, body.표시, body.판)
|
||||
|
||||
|
||||
@router.get("/structure-option-groups")
|
||||
def get_structure_option_groups() -> dict:
|
||||
"""`{판, 그룹, 종류}` — 그룹 = `{type_id: [{id, 이름, 옵션}]}` · 종류 = 레지스트리 전부."""
|
||||
return _call(groups.listing)
|
||||
|
||||
|
||||
@router.put("/structure-option-groups")
|
||||
def put_structure_option_groups(body: GroupsBody) -> dict:
|
||||
"""고쳐 씀(낡은 판 409 · 없는 종류 · 키 · 겹침 422) — 돌려주는 것 = 새 `{판, 그룹, 종류}`."""
|
||||
return _call(groups.save, body.그룹, body.판)
|
||||
|
||||
|
||||
@read_router.get("/layers/system/structure-options")
|
||||
def get_structure_options_read(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
|
||||
|
||||
@@ -61,15 +61,53 @@
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
/* 옵션 줄 = 왼쪽 보이기 체크 · 라벨 · 키 · 표시 / 오른쪽 들어갈 자리(본체 · 그룹) */
|
||||
.m02-opts__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-opts__check {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 옵션 그룹(PLAN 46-2) — 그룹 줄 = 이름 · 옵션 수 · 위로 · 아래로 · 지우기 */
|
||||
.m02-sog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-sog__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sog__name {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sog__place {
|
||||
flex: none;
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.m02-opts__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Text.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
|
||||
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면(보이기 · 그룹) 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 보일 옵션 고르기", "Choose options per type"],
|
||||
Screen: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
|
||||
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
|
||||
Note: [
|
||||
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
|
||||
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
|
||||
],
|
||||
GroupHead: ["그룹", "Groups"],
|
||||
GroupNote: [
|
||||
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
|
||||
"One group = one sub-container in the B05 · B06 form (title = group name) with its own structure combo · works without options · ungrouped options stay in the main body",
|
||||
],
|
||||
GroupAdd: ["그룹 추가", "Add group"],
|
||||
GroupName: ["그룹 이름", "Group name"],
|
||||
GroupNew: ["새 그룹 {value}", "New group {value}"],
|
||||
GroupUp: ["위로", "Up"],
|
||||
GroupDown: ["아래로", "Down"],
|
||||
GroupDel: ["지우기", "Delete"],
|
||||
GroupDelAsk: [
|
||||
"그룹 「{value}」 을 지움 — 그 옵션은 본체로 돌아감 · 프로젝트에 이미 지정한 조합은 값만 남음",
|
||||
"Delete group “{value}” — its options return to the main body; project links keep their values",
|
||||
],
|
||||
GroupCount: ["옵션 {value}", "{value} options"],
|
||||
GroupNone: [
|
||||
"그룹 없음 — 모든 옵션이 본체 · 집계 항목 하나",
|
||||
"No groups — all options in the main body",
|
||||
],
|
||||
GroupTag: ["그룹 {value}", "{value} groups"],
|
||||
Main: ["본체", "Main body"],
|
||||
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
|
||||
NoOptionKind: [
|
||||
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
|
||||
"No options — groups only (each links a combo)",
|
||||
],
|
||||
NameBad: [
|
||||
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
|
||||
"Group names: 1–40 chars, unique",
|
||||
],
|
||||
Group: ["{value}군", "Group {value}"],
|
||||
Hidden: ["숨김 {value}", "{value} hidden"],
|
||||
Off: ["안 쓰는 종류", "Not offered"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2).
|
||||
|
||||
못박는 것
|
||||
- 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
|
||||
- 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감.
|
||||
- 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부).
|
||||
- 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 ·
|
||||
거절되면 안 씀.
|
||||
- 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500.
|
||||
- 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드).
|
||||
"""
|
||||
|
||||
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_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
URL = "/api/m02/structure-option-groups"
|
||||
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 _two_options() -> tuple[str, str, str]:
|
||||
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 _groups(client: TestClient) -> dict[str, list[dict]]:
|
||||
return {t["type_id"]: t["groups"] for t in client.get(TYPES).json()["types"]}
|
||||
|
||||
|
||||
def test_시드는_빈_표_없으면_그룹_없음(client) -> None:
|
||||
seed = ROOT / "resources/master_template/구조물옵션그룹.json"
|
||||
assert json.loads(seed.read_text("utf-8")) == {}
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["그룹"] == {}
|
||||
assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도
|
||||
assert all(v == [] for v in _groups(client).values())
|
||||
|
||||
|
||||
def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) -> None:
|
||||
tid, a, b = _two_options()
|
||||
empty = next(t.type_id for t in load_structure_types() if not t.options)
|
||||
body = {
|
||||
tid: [
|
||||
{"id": "g1", "이름": "유입구", "옵션": [b]},
|
||||
{"id": "g2", "이름": " 빈 그룹 ", "옵션": []},
|
||||
],
|
||||
empty: [{"id": "gx", "이름": "부속", "옵션": []}],
|
||||
}
|
||||
res = client.put(URL, json={"판": "", "그룹": body})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움
|
||||
seen = _groups(client)
|
||||
assert seen[tid] == [
|
||||
{"id": "g1", "name": "유입구", "options": [b]},
|
||||
{"id": "g2", "name": "빈 그룹", "options": []},
|
||||
]
|
||||
assert seen[empty] == [{"id": "gx", "name": "부속", "options": []}]
|
||||
assert a not in seen[tid][0]["options"] # 안 넣은 옵션 = 본체
|
||||
|
||||
|
||||
def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
|
||||
tid, a, b = _two_options()
|
||||
ok = {"id": "g1", "이름": "가", "옵션": [a]}
|
||||
for bad in (
|
||||
{"없는종류": [ok]},
|
||||
{tid: [{**ok, "옵션": ["없는키"]}]},
|
||||
{tid: [{**ok, "id": "G-1"}]},
|
||||
{tid: [ok, {**ok, "이름": "나", "옵션": []}]}, # id 겹침
|
||||
{tid: [{**ok, "이름": " "}]},
|
||||
{tid: [ok, {"id": "g2", "이름": "가", "옵션": []}]}, # 이름 겹침
|
||||
{tid: [ok, {"id": "g2", "이름": "나", "옵션": [a]}]}, # 옵션 두 그룹
|
||||
{tid: {"g1": ok}},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "그룹": bad}).status_code == 422, bad
|
||||
assert not (store.FOLDER / groups.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "그룹": {tid: [ok]}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "그룹": {}}).status_code == 409
|
||||
res = client.put(URL, json={"판": v1, "그룹": {}})
|
||||
assert res.status_code == 200 and res.json()["그룹"] == {}
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_그룹_없음(client) -> None:
|
||||
(store.FOLDER / groups.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(v == [] for v in _groups(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 "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
|
||||
assert "buildGroupBlock(" in ui and "buildPlaceSelect(" in ui
|
||||
@@ -117,9 +117,7 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "section(options.title, options.body)" in side
|
||||
# 차례 = 구조물 조합 → 옵션 → 도면 양식(시스템 층에만 · 해시 진입 `reveal` 이 이름으로 · 27-1d)
|
||||
assert side.index("옵션: section(options.title") < side.index(
|
||||
'도면: section(L("M02_KindDrawing")'
|
||||
)
|
||||
assert side.index("section(options.title") < side.index('도면: section(L("M02_KindDrawing")')
|
||||
# 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서
|
||||
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
|
||||
assert (
|
||||
|
||||
Reference in New Issue
Block a user