Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1

This commit is contained in:
2026-10-05 13:30:37 +09:00
12 changed files with 680 additions and 75 deletions
@@ -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 (