"""M02 구조물 정의 한 벌(`/api/m02/structure-definition` · PLAN 48-1). 못박는 것 - 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과. - 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403). - 저장마다 판 +1(지금 판에서) · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). - 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상. - 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존. """ from __future__ import annotations import json 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 B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures from B05_Profile.B05_Profile_Structures_Router import router as b05_router from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_structure_types from common_util.common_util_auth import verify_session from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router URL = "/api/m02/structure-definition" TYPES = "/api/projects/structure-types" ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} USER = {"role": "USER", "user_id": 2, "company_id": 1} @pytest.fixture def copy(tmp_path: Path, 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(session: dict) -> TestClient: app = FastAPI() app.include_router(read_router) app.include_router(b05_router) app.dependency_overrides[verify_session] = lambda: session return TestClient(app) def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: doc = definition.read_version(1) assert doc["판"] == 1 and len(doc["types"]) == 37 assert sum(len(t["options"]) for t in doc["types"]) == 235 assert definition.check(doc) == [] now = definition.read() assert now["판"] >= 1 and definition.check(now) == [] assert len(load_structure_types()) == len(now["types"]) assert sum(len(t.options) for t in load_structure_types()) == sum( len(t["options"]) for t in now["types"] ) assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"] assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"]) def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None: user = _client(USER) got = user.get(URL) v = json.loads(copy.read_text("utf-8"))["판"] assert got.status_code == 200 and got.json()["판"] == v assert user.put(URL, json={"판": v, "정의": got.json()}).status_code == 403 assert json.loads(copy.read_text("utf-8"))["판"] == v # 안 씀 def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None: admin = _client(ADMIN) doc = admin.get(URL).json() v = doc["판"] tid = doc["types"][0]["type_id"] doc["types"][0]["name"] = "이름 바꿈" res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 200, res.text assert res.json()["판"] == v + 1 old = json.loads((copy.parent / "구조물정의_이력" / f"판_{v}.json").read_text("utf-8")) assert old["판"] == v and old["types"][0]["name"] != "이름 바꿈" seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]} assert seen[tid]["name"] == "이름 바꿈" and len(seen) == len(doc["types"]) assert admin.put(URL, json={"판": v, "정의": doc}).status_code == 409 # 낡은 판 assert admin.put(URL, json={"판": v + 1, "정의": res.json()}).json()["판"] == v + 2 assert (copy.parent / "구조물정의_이력" / f"판_{v + 1}.json").is_file() def _rule(doc: dict) -> tuple[str, dict, dict]: """고르기 옵션 둘 이상 가진 종류 — (종류 키, 상위 옵션, 하위 옵션).""" for t in doc["types"]: picks = [o for o in t["options"] if o["input"] == "select"] if len(picks) >= 2: return t["type_id"], picks[0], picks[1] raise AssertionError("고르기 둘인 종류 없음") def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: admin = _client(ADMIN) base = admin.get(URL).json() v = base["판"] tid, up, down = _rule(base) good = { "종류": tid, "만약": {"옵션": up["key"], "값들": [up["choices"][0]]}, "그러면": {"옵션": down["key"], "선택지": down["choices"][:1], "보이기": True}, } assert definition.check({**base, "규칙": [good]}) == [] def bad(**change) -> dict: return json.loads(json.dumps({**base, **change})) dup = bad() dup["types"].append(dup["types"][0]) # 종류 키 겹침 opt_dup = bad() opt_dup["types"][0]["options"].append(opt_dup["types"][0]["options"][0]) # 옵션 키 겹침 shape = bad() shape["types"][0]["options"][0]["input"] = "날짜" # 없는 꼴 cases = [ dup, opt_dup, shape, bad(규칙=[{**good, "그러면": {**good["그러면"], "옵션": "없는키"}}]), # 대상 없음 bad(규칙=[{**good, "종류": "없는종류"}]), bad(규칙=[{**good, "만약": {"옵션": up["key"], "값들": ["없는값"]}}]), bad(규칙=[{**good, "그러면": {"옵션": down["key"], "선택지": ["없는값"]}}]), bad(규칙=[{**good, "그러면": {"옵션": down["key"]}}]), # 효과 없음 bad(배치={tid: {up["key"]: {"줄": 0, "칸": 1}}}), bad(배치={tid: {"없는키": {"줄": 1}}}), bad(종류={tid: {"동작": ["없는동작"]}}), bad(종류={"없는종류": {"기준측점": False}}), bad(표시={tid: {"없는키": False}}), bad( 그룹={"pipe": [{"id": "g1", "이름": "가", "옵션": [], "형상": {"폭": {"받기": True}}}]} ), bad(모르는칸=1), ] for i, doc in enumerate(cases): res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 422, (i, res.text) assert json.loads(copy.read_text("utf-8"))["판"] == v # 거절되면 안 씀 assert not (copy.parent / "구조물정의_이력").exists() shaped = bad(종류={tid: {"기준측점": False}}) shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}} assert admin.put(URL, json={"판": v, "정의": shaped}).status_code == 200 def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None: root = str(tmp_path / "project") item = StructureInstance.model_validate( { "type_id": "mirror", "placement": "point", "chainage_m": 10.0, "options": {"지난정의키": "옛값"}, } ) assert save_structures(root, [item], base_revision=0) == 1 raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8")) assert raw["definition_version"] == definition.read()["판"] assert load_structures(root)[1][0].options["지난정의키"] == "옛값"