Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5
This commit is contained in:
@@ -12,7 +12,11 @@ import os
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureInstance,
|
||||
definition_version,
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
|
||||
|
||||
@@ -125,6 +129,8 @@ def save_structures(
|
||||
"revision": revision,
|
||||
"structures": [item.model_dump(mode="json") for item in items],
|
||||
"migrated_legacy": sorted(history),
|
||||
# 저장 때 구조물 정의 판(PLAN 48-1) — 뒤에 정의가 바뀌어도 이 값이 어느 정의로 적혔는지 앎
|
||||
"definition_version": definition_version(),
|
||||
}
|
||||
atomic_write_json(structures_file_path(project_root), payload)
|
||||
return revision
|
||||
@@ -192,12 +198,15 @@ def _validate_options(item: StructureInstance, definition, hidden: set[str] = fr
|
||||
for key, value in item.options.items():
|
||||
option = allowed.get(key)
|
||||
if option is None:
|
||||
raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}")
|
||||
# 지금 정의에 없는 키 = 지난 정의 값 — 버리지 않고 그대로 남김(PLAN 48-0 ② · 오류 없음)
|
||||
continue
|
||||
if option.input == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.")
|
||||
if not (value == value and abs(value) != float("inf")) or value < 0:
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.")
|
||||
elif option.input == "bool" and not isinstance(value, bool):
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 예 · 아니오여야 합니다.")
|
||||
elif option.input == "select" and option.choices and str(value) not in option.choices:
|
||||
raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}")
|
||||
for option in definition.options:
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""B05 구조물 타입 레지스트리·인스턴스 검증 모델.
|
||||
|
||||
구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가
|
||||
바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다.
|
||||
구조물 타입 정의는 코드가 아니라 데이터(M02 마스터 `resources/master_template/구조물정의.json`
|
||||
`types` · PLAN 48-1)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는
|
||||
`GET /structure-types`로 같은 정본을 받는다. 정의 틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`.
|
||||
|
||||
인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정).
|
||||
DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의
|
||||
"복원 유령" 전례).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
@@ -21,8 +23,12 @@ from common_util.common_util_summary_item import parse_group_items, parse_summar
|
||||
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
||||
STRUCTURE_SIDES = ("left", "right", "center", "cross")
|
||||
|
||||
_REGISTRY_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json"
|
||||
# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음
|
||||
DEFINITION_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"resources",
|
||||
"master_template",
|
||||
"구조물정의.json",
|
||||
)
|
||||
|
||||
Placement = Literal["point", "interval", "site"]
|
||||
@@ -35,7 +41,7 @@ class StructureOptionField(BaseModel):
|
||||
|
||||
key: str
|
||||
label: str
|
||||
input: Literal["select", "number", "text"]
|
||||
input: Literal["select", "number", "text", "bool"]
|
||||
choices: list[str] = Field(default_factory=list)
|
||||
unit: str | None = None
|
||||
default: Any = None
|
||||
@@ -99,14 +105,37 @@ class StructureType(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_structure_types() -> tuple[StructureType, ...]:
|
||||
"""레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드)."""
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
def _stamp() -> tuple[str, int, int]:
|
||||
stat = os.stat(DEFINITION_PATH)
|
||||
return DEFINITION_PATH, stat.st_mtime_ns, stat.st_size
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
|
||||
payload = _read_definition(path, mtime, size)
|
||||
return tuple(StructureType.model_validate(item) for item in payload["types"])
|
||||
|
||||
|
||||
def load_definition() -> dict[str, Any]:
|
||||
"""구조물 정의 한 벌(사본) — `{판, types, 표시, 그룹, 종류, 배치, 규칙, 동작, …}`."""
|
||||
return copy.deepcopy(_read_definition(*_stamp()))
|
||||
|
||||
|
||||
def definition_version() -> int:
|
||||
return int(_read_definition(*_stamp()).get("판", 1))
|
||||
|
||||
|
||||
def load_structure_types() -> tuple[StructureType, ...]:
|
||||
"""정의 정본을 읽어 타입 목록을 돌려준다(파일이 바뀔 때만 다시 읽음)."""
|
||||
return _read_types(*_stamp())
|
||||
|
||||
|
||||
def structure_type_map() -> dict[str, StructureType]:
|
||||
return {item.type_id: item for item in load_structure_types()}
|
||||
|
||||
@@ -132,8 +161,7 @@ def station_planting_labels() -> set[str]:
|
||||
|
||||
|
||||
def registry_schema_version() -> int:
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
return int(json.load(handle).get("schema_version", 1))
|
||||
return int(_read_definition(*_stamp()).get("schema_version", 1))
|
||||
|
||||
|
||||
class StructureInstance(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Api.ts
|
||||
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
|
||||
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
|
||||
* (마스터 템플릿 `구조물정의.json` 「표시」 · 「그룹」 칸 · PLAN 48-1)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "../M02_MasterTemplete_Api_Fetch";
|
||||
@@ -29,7 +29,7 @@ export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
|
||||
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
|
||||
/* --- 옵션 그룹(PLAN 46-2 · 구조물 정의 「그룹」 칸 · 시스템 관리자만) --- */
|
||||
|
||||
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
|
||||
export interface OptionGroupDef {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""M02 구조물 정의 한 벌(PLAN 48-1) — 정본 `resources/master_template/구조물정의.json`.
|
||||
|
||||
틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만.
|
||||
판 = 정수 · 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` 으로 남김(지난 정의 값 읽기용).
|
||||
검사 = 칸 꼴 · 키 겹침 · 표시 · 그룹 · 배치 · 규칙 대상 있음 — 틀리면 422 · 낡은 판 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
FILE = "구조물정의.json"
|
||||
HISTORY = "구조물정의_이력"
|
||||
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"}
|
||||
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
|
||||
SPOT = ("줄", "칸", "너비")
|
||||
|
||||
|
||||
def read() -> dict[str, Any]:
|
||||
return schema.load_definition()
|
||||
|
||||
|
||||
def history_path(version: int) -> Path:
|
||||
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
|
||||
|
||||
|
||||
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
|
||||
errors: list[str] = []
|
||||
keys: dict[str, dict[str, Any]] = {}
|
||||
groups = doc.get("구조물군")
|
||||
if not isinstance(groups, dict):
|
||||
errors.append("구조물군은 {키: 이름} 묶음이어야 함")
|
||||
groups = {}
|
||||
for row in doc.get("types") if isinstance(doc.get("types"), list) else []:
|
||||
try:
|
||||
t = schema.StructureType.model_validate(row)
|
||||
except ValidationError as e:
|
||||
errors.append(
|
||||
f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}"
|
||||
)
|
||||
continue
|
||||
if t.type_id in keys:
|
||||
errors.append(f"종류 키 「{t.type_id}」 겹침")
|
||||
if t.group not in groups:
|
||||
errors.append(f"「{t.type_id}」 구조물군 「{t.group}」 이 구조물군에 없음")
|
||||
opts: dict[str, Any] = {}
|
||||
for o in t.options:
|
||||
if o.key in opts:
|
||||
errors.append(f"「{t.type_id}」 옵션 키 「{o.key}」 겹침")
|
||||
if o.input == "select" and not o.choices:
|
||||
errors.append(f"「{t.type_id}.{o.key}」 고르기 꼴은 선택지가 있어야 함")
|
||||
opts[o.key] = o
|
||||
keys[t.type_id] = opts
|
||||
if not isinstance(doc.get("types"), list) or not keys:
|
||||
errors.append("types 는 종류 목록이어야 함")
|
||||
return errors, keys
|
||||
|
||||
|
||||
def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름]}`."""
|
||||
errors: list[str] = []
|
||||
acts = doc.get("동작") if isinstance(doc.get("동작"), dict) else {}
|
||||
for tid, row in doc["종류"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"종류 덧칸 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for name, value in row.items():
|
||||
if name == "기준측점" and isinstance(value, bool):
|
||||
continue
|
||||
if name == "동작" and isinstance(value, list) and all(a in acts for a in value):
|
||||
continue
|
||||
errors.append(
|
||||
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들)"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _spot_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""배치 — `{종류: {옵션 키: {줄, 칸, 너비}}}` · 1 이상 정수."""
|
||||
errors: list[str] = []
|
||||
for tid, row in doc["배치"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"배치 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for key, spot in row.items():
|
||||
ok = isinstance(spot, dict) and set(spot) <= set(SPOT)
|
||||
ok = ok and all(type(v) is int and v >= 1 for v in spot.values())
|
||||
if key not in keys[tid] or not ok:
|
||||
errors.append(
|
||||
f"배치 「{tid}.{key}」 — 없는 옵션이거나 줄 · 칸 · 너비가 1 이상 정수 아님"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""규칙 — `{종류, 만약: {옵션, 값들}, 그러면: {옵션, 선택지? · 보이기? · 기본값?}}`."""
|
||||
errors: list[str] = []
|
||||
for i, rule in enumerate(doc["규칙"], 1):
|
||||
where = f"규칙 {i}"
|
||||
if not isinstance(rule, dict) or rule.get("종류") not in keys:
|
||||
errors.append(f"{where} — 없는 종류")
|
||||
continue
|
||||
opts = keys[rule["종류"]]
|
||||
when, then = rule.get("만약"), rule.get("그러면")
|
||||
if not isinstance(when, dict) or not isinstance(then, dict):
|
||||
errors.append(f"{where} — 만약 · 그러면은 묶음이어야 함")
|
||||
continue
|
||||
src, dst = opts.get(when.get("옵션")), opts.get(then.get("옵션"))
|
||||
if src is None or dst is None or src is dst:
|
||||
errors.append(f"{where} — 만약 · 그러면 옵션이 없거나 같음")
|
||||
continue
|
||||
values = when.get("값들")
|
||||
if not isinstance(values, list) or not values:
|
||||
errors.append(f"{where} — 값들이 비었음")
|
||||
elif src.input == "select" and not set(map(str, values)) <= set(src.choices):
|
||||
errors.append(f"{where} — 값들이 「{src.key}」 선택지에 없음")
|
||||
effects = set(then) - {"옵션"}
|
||||
if not effects or not effects <= {"선택지", "보이기", "기본값"}:
|
||||
errors.append(f"{where} — 그러면은 선택지 · 보이기 · 기본값 중 하나 이상")
|
||||
allowed = dst.choices
|
||||
if "선택지" in then:
|
||||
pick = then["선택지"]
|
||||
if dst.input != "select" or not isinstance(pick, list) or not set(pick) <= set(allowed):
|
||||
errors.append(f"{where} — 선택지는 「{dst.key}」 고르기 선택지 안에서만")
|
||||
else:
|
||||
allowed = pick
|
||||
if "보이기" in then and not isinstance(then["보이기"], bool):
|
||||
errors.append(f"{where} — 보이기는 참거짓")
|
||||
if "기본값" in then and dst.input == "select" and str(then["기본값"]) not in allowed:
|
||||
errors.append(f"{where} — 기본값이 「{dst.key}」 선택지에 없음")
|
||||
return errors
|
||||
|
||||
|
||||
def check(doc: Any) -> list[str]:
|
||||
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과)."""
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
return ["정의는 묶음이어야 함"]
|
||||
errors = [f"모르는 칸 「{k}」" for k in doc if k not in TOP and k not in SECTIONS]
|
||||
errors += [
|
||||
f"「{k}」 꼴이 틀림" for k, kind in SECTIONS.items() if not isinstance(doc.get(k), kind)
|
||||
]
|
||||
if not isinstance(doc.get("동작"), dict):
|
||||
errors.append("동작은 {이름: 뜻} 묶음이어야 함")
|
||||
if errors:
|
||||
return errors
|
||||
found, keys = _type_errors(doc)
|
||||
errors += found
|
||||
plain = {tid: set(opts) for tid, opts in keys.items()}
|
||||
for fn, section in ((options._clean, "표시"), (groups._clean, "그룹")):
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
|
||||
"""판이 같을 때만 고쳐 씀 — 앞 판은 이력으로 · 새 판 = 앞 판 + 1."""
|
||||
path = Path(schema.DEFINITION_PATH)
|
||||
with store._LOCK:
|
||||
current = read()
|
||||
have = int(current.get("판", 1))
|
||||
if str(version) != str(have):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
doc = {**change(current), "판": have}
|
||||
errors = check(doc)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
atomic_write_json(history_path(have), current)
|
||||
atomic_write_json(path, {**doc, "판": have + 1})
|
||||
return read()
|
||||
|
||||
|
||||
def save(doc: Any, version: Any) -> dict[str, Any]:
|
||||
"""정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의."""
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(400, "정의는 묶음이어야 함")
|
||||
return _commit(lambda _: dict(doc), version)
|
||||
|
||||
|
||||
def save_sections(changes: dict[str, Any], version: Any) -> dict[str, Any]:
|
||||
"""정의 몇 칸만 고쳐 씀(옵션 보이기 · 그룹 화면)."""
|
||||
return _commit(lambda current: {**current, **changes}, version)
|
||||
@@ -1,45 +1,34 @@
|
||||
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
|
||||
|
||||
정본 = `resources/master_template/구조물옵션그룹.json` =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
|
||||
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
|
||||
정본 = 구조물 정의 한 벌(`M02_SO_Definition` · PLAN 48-1)의 「그룹」 칸 =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": [...], "형상"?: {...}}]}`
|
||||
— 없는 종류 = 그룹 없음 · 빈 그룹(옵션 0)도 그룹 · 형상 = 길이 · 높이 · 전 · 후 받기 + 뜻.
|
||||
|
||||
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
|
||||
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
|
||||
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
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 B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
|
||||
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
|
||||
SHAPE = ("길이", "높이", "전", "후") # 그룹이 받을 형상 칸 — `{칸: {받기: 참거짓, 뜻: 글}}`
|
||||
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)
|
||||
"""(그룹 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「그룹」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("그룹") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def groups_of() -> dict[str, list[dict[str, Any]]]:
|
||||
@@ -83,11 +72,23 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "그룹": doc, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
|
||||
def _shape_ok(shape: Any) -> bool:
|
||||
return isinstance(shape, dict) and all(
|
||||
k in SHAPE
|
||||
and isinstance(v, dict)
|
||||
and isinstance(v.get("받기"), bool)
|
||||
and isinstance(v.get("뜻", ""), str)
|
||||
and set(v) <= {"받기", "뜻"}
|
||||
for k, v in shape.items()
|
||||
)
|
||||
|
||||
|
||||
def _clean(groups: Any, keys: dict[str, set[str]] | None = None) -> 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()}
|
||||
if keys is None:
|
||||
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():
|
||||
@@ -126,9 +127,14 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
)
|
||||
else:
|
||||
owner[key] = name
|
||||
if "형상" in row and not _shape_ok(row["형상"]):
|
||||
errors.append(
|
||||
f"「{type_id}.{name}」 형상은 {{길이 · 높이 · 전 · 후: {{받기, 뜻}}}}"
|
||||
)
|
||||
ids.add(gid)
|
||||
names.add(name)
|
||||
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
|
||||
kept = {"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]}
|
||||
clean.append({**kept, "형상": row["형상"]} if "형상" in row else kept)
|
||||
if clean:
|
||||
out[type_id] = clean
|
||||
if errors:
|
||||
@@ -138,11 +144,7 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
|
||||
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)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"그룹": _clean(groups)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
|
||||
|
||||
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
|
||||
정본 = 구조물 정의(`M02_SO_Definition` · PLAN 48-1) 「표시」 칸 = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임.
|
||||
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
|
||||
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -19,14 +18,13 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
StructureTypesResponse,
|
||||
load_definition,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,18 +54,9 @@ class TypesWithVisible(StructureTypesResponse):
|
||||
|
||||
|
||||
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)
|
||||
"""(표시 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「표시」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("표시") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
@@ -138,11 +127,12 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "표시": hidden, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
def _clean(shown: Any, keys: dict[str, set[str]] | None = None) -> dict[str, dict[str, bool]]:
|
||||
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
|
||||
if not isinstance(shown, dict):
|
||||
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
if keys is None:
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for type_id, row in shown.items():
|
||||
@@ -165,11 +155,7 @@ def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
|
||||
def save(shown: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(shown)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"표시": _clean(shown)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
|
||||
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
|
||||
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
|
||||
* 서버 = `M02_SO_Options.py`(정의 「표시」 · 숨긴 것만) · `M02_SO_Groups.py`(정의 「그룹」).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`.
|
||||
"""M02 구조물 옵션 보이기 · 그룹 · 정의 길 — 정본 `M02_SO_Definition.py`(PLAN 48-1) 한 벌.
|
||||
|
||||
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임) · 정의 쓰기는 여기서
|
||||
시스템 관리자만(`access.require(write=True)` · 아니면 403).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,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_Definition as definition
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
@@ -31,6 +33,11 @@ class GroupsBody(BaseModel):
|
||||
그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]}
|
||||
|
||||
|
||||
class DefinitionBody(BaseModel):
|
||||
판: int # 읽어 간 정의 판 — 어긋나면 409
|
||||
정의: dict[str, Any] # 정의 한 벌(틀 = docs/raw/M02_구조물옵션정의/정의_틀.md)
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
with store.at(store.FOLDER):
|
||||
try:
|
||||
@@ -68,3 +75,17 @@ def get_structure_options_read(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition")
|
||||
def get_structure_definition(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 정의 한 벌(판 · 구조물군 · 동작 · types · 종류 · 표시 · 그룹 · 배치 · 규칙)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read)
|
||||
|
||||
|
||||
@read_router.put("/structure-definition")
|
||||
def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict:
|
||||
"""정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력."""
|
||||
access.require(session, "system", None, write=True)
|
||||
return _call(definition.save, body.정의, body.판)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 구조물 정의 틀 (PLAN 48-1)
|
||||
|
||||
정본 = `resources/master_template/구조물정의.json` 한 벌. 서버 · 화면은 이 한 벌만 읽음.
|
||||
|
||||
## 1. 길
|
||||
|
||||
| 길 | 누가 | 하는 것 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/m02/structure-definition` | 로그인 누구나 | 정의 한 벌 그대로 |
|
||||
| `PUT /api/m02/structure-definition` `{판, 정의}` | 시스템 관리자만(아니면 403) | 통째로 고쳐 씀 · 낡은 판 409 · 검사 틀림 422 |
|
||||
| `GET /api/projects/structure-types` | 로그인 | 정의의 `types` + `표시` · `그룹` 을 옛 꼴 그대로(`visible` · `groups`) |
|
||||
| `PUT /api/m02/structure-options` · `structure-option-groups` | 시스템 관리자 | 정의의 `표시` · `그룹` 칸만 고쳐 씀(같은 판 · 같은 이력) |
|
||||
|
||||
- 서버 읽기 = `B05_Profile_Structures_Schema.load_definition()` · `load_structure_types()` · 쓰기 = `M02_SO_Definition.save()` · `save_sections()`.
|
||||
|
||||
## 2. 담는 것
|
||||
|
||||
| 칸 | 꼴 | 뜻 |
|
||||
| --- | --- | --- |
|
||||
| `판` | 정수 | 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` |
|
||||
| `schema_version` · `comment` | 수 · 글 | 옛 레지스트리 머리 그대로 |
|
||||
| `구조물군` | `{군 키: 이름}` | 구조물군 이름(A 횡단배수 …) |
|
||||
| `동작` | `{동작 이름: 뜻}` | 손코딩 특수 동작 목록(5장) |
|
||||
| `types` | 종류 목록 | 종류 · 옵션 · 선택지 · 기본값 · placement 등(옛 `B05_Profile_Structure_Types.json` 꼴 그대로) |
|
||||
| `종류` | `{종류 키: {기준측점?, 동작?}}` | 종류마다 덧칸 — 없으면 기본 |
|
||||
| `표시` | `{종류 키: {옵션 키: false}}` | 끈 옵션만(옛 `구조물옵션표시.json`) |
|
||||
| `그룹` | `{종류 키: [그룹]}` | 옵션 그룹(옛 `구조물옵션그룹.json` · 46장) + 형상 칸 |
|
||||
| `배치` | `{종류 키: {옵션 키: {줄, 칸, 너비}}}` | B05 · B06 좌측 「구조물 배치」 칸 자리 — 없으면 지금 차례 |
|
||||
| `규칙` | 규칙 목록 | 상위 옵션 값 → 하위 옵션(4장) |
|
||||
|
||||
## 3. 키 규칙
|
||||
|
||||
- 키 = 만들 때 자동 · 안 바뀜 · 영문 소문자 · 숫자 · `_`. 이름(`name` · `label` · 선택지 글 · 그룹 `이름`)은 자유.
|
||||
- 종류 키(`type_id`) 는 정의 안에서 하나 · 옵션 키(`key`) 는 종류 안에서 하나 · 그룹 id 는 `g` + 소문자 · 숫자.
|
||||
- 지움 = 「안 씀」(`enabled: false`) — 종류 · 옵션을 목록에서 빼지 않음.
|
||||
- 저장된 값의 키가 지금 정의에 없으면 = 지난 정의 값 — 버리지 않고 남김(오류 없음 · 계산은 없는 값으로).
|
||||
- 프로젝트 `structures.json` 에 저장 때 정의 판(`definition_version`).
|
||||
|
||||
## 4. 옵션
|
||||
|
||||
### 꼴
|
||||
|
||||
| `input` | 이름 | 값 |
|
||||
| --- | --- | --- |
|
||||
| `select` | 고르기 | `choices` 중 하나(선택지 있어야 함) |
|
||||
| `number` | 숫자 | 0 이상 수 · `unit` |
|
||||
| `text` | 글 | 글 |
|
||||
| `bool` | 예/아니오 | 참 · 거짓 |
|
||||
|
||||
- 옵션 덧칸(옛 그대로) = `label` · `choices` · `unit` · `default` · `required` · `phase`(b05 · detail) · `enabled` · `empty_means` · `default_basis` · `warn_above` · `warn_message` · `not_in_table`.
|
||||
|
||||
### 배치 칸
|
||||
|
||||
- `줄` = 몇째 줄(1부터) · `칸` = 그 줄 몇째 칸(1부터) · `너비` = 칸 몇 개 차지(1부터). 셋 다 1 이상 정수.
|
||||
|
||||
### 그룹
|
||||
|
||||
```
|
||||
{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["옵션 키", …],
|
||||
"형상": {"길이": {"받기": true, "뜻": "유입구 기슭막이 연장"}, "높이": {…}, "전": {…}, "후": {…}}}
|
||||
```
|
||||
|
||||
- 옵션 하나는 한 종류 안에서 그룹 하나에만 · 빈 그룹(옵션 0)도 그룹 · 그룹에 안 넣은 옵션 = 본체 칸.
|
||||
- `형상` = 그룹이 받을 형상 칸(길이 · 높이 · 기준 측점 전 · 후) — 칸마다 `받기`(켬끔) + `뜻`(무엇을 정하는지). 없는 칸 = 안 받음.
|
||||
|
||||
### 종류 덧칸
|
||||
|
||||
- `기준측점` = 종류 맨 위 기준 측점 칸 보이기(없으면 보임).
|
||||
- `동작` = 이 종류에 붙인 특수 동작 이름들(`동작` 목록 안에서).
|
||||
|
||||
## 5. 규칙 꼴
|
||||
|
||||
```
|
||||
{"종류": "종류 키",
|
||||
"만약": {"옵션": "상위 옵션 키", "값들": ["값", …]},
|
||||
"그러면": {"옵션": "하위 옵션 키", "선택지": ["값", …], "보이기": false, "기본값": "값"}}
|
||||
```
|
||||
|
||||
- 만약 상위 옵션 = 값들 중 하나 → 그러면 하위 옵션의 선택지 / 보이기 / 기본값(셋 중 하나 이상).
|
||||
- 상위 · 하위는 같은 종류 안 · 서로 다른 옵션 · 고르기 꼴이면 값들은 선택지 안 · 하위 `선택지` 는 고르기 꼴의 선택지 안 · `기본값` 은 (좁힌) 선택지 안.
|
||||
- 계산 · 연동 제어는 넣지 않음(48-5).
|
||||
|
||||
## 6. 동작 이름(손코딩 특수 동작)
|
||||
|
||||
| 이름 | 붙은 종류 | 하는 것 |
|
||||
| --- | --- | --- |
|
||||
| `계곡통과시설` | pipe · box_culvert · ford_pavement · ford_bridge · revetment | 관 지점 정본 시설 — 부속 옵션 서브폼 · 저장은 폼이 아는 칸만 |
|
||||
| `유입유출_묶음` | pipe | 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹 |
|
||||
| `기슭막이_한벌` | pipe · revetment | 형태 · 길이 · 높이 · 바닥 보호공 한 벌 |
|
||||
| `BOX_본체규격` | box_culvert | 본체 규격 프리셋(폭 × 높이) |
|
||||
| `날개벽` | box_culvert · ford_bridge | 날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 |
|
||||
| `월류단면` | ford_pavement · ford_bridge | 월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 · 필요 수심 미만 되돌림 |
|
||||
| `포장_물넘이_겹침` | pavement_concrete | 포장 구간이 물넘이를 품으면 나눔 · 끝만 걸치면 저장 멈춤 |
|
||||
|
||||
- 정의에서 붙이고 뗌 = `종류.{키}.동작` · 새 동작은 코딩 뒤 목록에 이름을 더함.
|
||||
|
||||
## 7. 판 · 이력
|
||||
|
||||
- 저장 = 읽어 간 `판` 을 같이 보냄 → 같으면 앞 판을 `구조물정의_이력/판_N.json` 으로 남기고 새 판 = N + 1.
|
||||
- 검사(틀리면 422 · 안 씀) = 칸 꼴 · 종류 키 · 옵션 키 겹침 · 고르기 선택지 있음 · 구조물군 있음 · 표시 · 그룹 · 배치 · 종류 덧칸 · 규칙 대상 있음.
|
||||
- 판 1 = 옛 `B05_Profile_Structure_Types.json`(37종 · 옵션 235) + `구조물옵션표시.json` + `구조물옵션그룹.json` 을 키 그대로 옮김.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"pipe": [
|
||||
{
|
||||
"id": "grv6lny",
|
||||
"이름": "관",
|
||||
"옵션": [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"inlet_type"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gnbf9m9",
|
||||
"이름": "관 보호공(유입)",
|
||||
"옵션": []
|
||||
},
|
||||
{
|
||||
"id": "g9lq9uk",
|
||||
"이름": "관 보호공(유출)",
|
||||
"옵션": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
+730
-130
File diff suppressed because it is too large
Load Diff
@@ -192,10 +192,11 @@ def test_placement_mismatching_registry_is_rejected(project_root):
|
||||
save_structures(project_root, [wall_as_point], base_revision=0)
|
||||
|
||||
|
||||
def test_undefined_option_key_is_rejected(project_root):
|
||||
bad = _point(options={"form": "돌", "no_such_option": 1})
|
||||
with pytest.raises(ValueError, match="옵션"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
def test_undefined_option_key_is_kept(project_root):
|
||||
"""지금 정의에 없는 키 = 지난 정의 값 — 오류 없이 그대로 남김(PLAN 48-1)."""
|
||||
old = _point(options={"form": "돌", "no_such_option": 1})
|
||||
assert save_structures(project_root, [old], base_revision=0) == 1
|
||||
assert load_structures(project_root)[1][0].options["no_such_option"] == 1
|
||||
|
||||
|
||||
def test_negative_number_option_is_rejected(project_root):
|
||||
|
||||
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
def _box_options() -> dict[str, dict]:
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = registry if isinstance(registry, list) else registry.get("types") or []
|
||||
box = next(t for t in types if t["type_id"] == "box_culvert")
|
||||
|
||||
@@ -32,7 +32,7 @@ QUANTITY_ONLY = {
|
||||
|
||||
def _pipe_option_keys() -> list[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else payload["types"]
|
||||
pipe = next(item for item in types if item["type_id"] == "pipe")
|
||||
|
||||
@@ -20,7 +20,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
|
||||
|
||||
REGISTRY = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ KNOWN_ABSENT: dict[str, str] = {}
|
||||
|
||||
def _registry_keys() -> set[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else (payload.get("types") or [])
|
||||
return {option["key"] for item in types for option in item.get("options") or []}
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_사방_종류_이름이_레지스트리와_맞을것() -> None:
|
||||
import json
|
||||
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = registry if isinstance(registry, list) else (registry.get("types") or [])
|
||||
known = {t["type_id"] for t in types}
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_바닥막이는_제곱미터당() -> None:
|
||||
|
||||
def test_개거_L형수로는_등록부_글자로_선다() -> None:
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
choices = next(
|
||||
option["choices"]
|
||||
|
||||
@@ -29,7 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
|
||||
|
||||
def _registry_choices() -> list[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else payload["types"]
|
||||
pipe = next(item for item in types if item["type_id"] == "pipe")
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2).
|
||||
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · 정의 「그룹」 칸 · PLAN 46-2).
|
||||
|
||||
못박는 것
|
||||
- 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
|
||||
- 「그룹」 이 `{}` 면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
|
||||
- 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감.
|
||||
- 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부).
|
||||
- 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 ·
|
||||
거절되면 안 씀.
|
||||
- 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500.
|
||||
- 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드).
|
||||
"""
|
||||
|
||||
@@ -19,10 +18,10 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
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]
|
||||
@@ -32,7 +31,12 @@ TYPES = "/api/projects/structure-types"
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
|
||||
copy = tmp_path / "구조물정의.json"
|
||||
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
@@ -48,11 +52,9 @@ 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")) == {}
|
||||
def test_빈_그룹_칸은_그룹_없음(client) -> None:
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["그룹"] == {}
|
||||
assert got["판"] == "1" and got["그룹"] == {}
|
||||
assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도
|
||||
assert all(v == [] for v in _groups(client).values())
|
||||
|
||||
@@ -67,7 +69,7 @@ def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) ->
|
||||
],
|
||||
empty: [{"id": "gx", "이름": "부속", "옵션": []}],
|
||||
}
|
||||
res = client.put(URL, json={"판": "", "그룹": body})
|
||||
res = client.put(URL, json={"판": "1", "그룹": body})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움
|
||||
seen = _groups(client)
|
||||
@@ -92,20 +94,14 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
|
||||
{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={"판": "1", "그룹": bad}).status_code == 422, bad
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "1", "그룹": {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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정의 「표시」 칸 · PLAN 48-1).
|
||||
|
||||
못박는 것
|
||||
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 「표시」 가 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
|
||||
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
|
||||
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,10 +15,10 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
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_Options as opt
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -33,7 +32,12 @@ def structure_type(type_id):
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
|
||||
copy = tmp_path / "구조물정의.json"
|
||||
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
@@ -51,13 +55,9 @@ def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
|
||||
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
|
||||
|
||||
|
||||
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
assert (
|
||||
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
|
||||
== {}
|
||||
)
|
||||
def test_빈_표시는_모두_보임(client) -> None:
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["표시"] == {} # 파일 없음
|
||||
assert got["판"] == "1" and got["표시"] == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(TYPES).json()["schema_version"] == 1
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
|
||||
tid, off, on = _pick()
|
||||
before = client.get(TYPES).json()["types"]
|
||||
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
|
||||
res = client.put(URL, json={"판": "1", "표시": {tid: {off: False, on: True}}})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
|
||||
seen = _visible(client)
|
||||
@@ -83,10 +83,10 @@ def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client)
|
||||
|
||||
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"]
|
||||
res = client.put(URL, json={"판": v1, "표시": {}})
|
||||
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
|
||||
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["표시"] == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
|
||||
|
||||
@@ -98,21 +98,15 @@ def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) ->
|
||||
{tid: {off: "no"}},
|
||||
{tid: [off]},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "1", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "1", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
|
||||
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.put(URL, json={"판": "1", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.get(URL).json()["판"] == v1
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
|
||||
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림
|
||||
|
||||
|
||||
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "section(options.title, options.body)" in side
|
||||
@@ -165,7 +159,7 @@ def test_숨긴_옵션은_필수_아님_저장이_안_막힘(client, tmp_path, m
|
||||
root = str(tmp_path / "project")
|
||||
with pytest.raises(ValueError, match="필수"):
|
||||
repo.save_structures(root, [item({})], base_revision=0) # 보임 = 필수 그대로
|
||||
(store.FOLDER / opt.FILE).write_text('{"fake_hide": {"a": false}}', encoding="utf-8")
|
||||
monkeypatch.setattr(repo, "hidden_keys", lambda: {"fake_hide": {"a"}})
|
||||
with pytest.raises(ValueError, match="필수"): # a 는 숨겨 통과 · b 는 여전히 필수
|
||||
repo.save_structures(root, [item({})], base_revision=0)
|
||||
assert (
|
||||
|
||||
Reference in New Issue
Block a user