From c6d78f40699a95103000726e25850ca4ef319e2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:14:12 +0900 Subject: [PATCH] =?UTF-8?q?test(M02):=2048-1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=EC=8B=9C=ED=97=98=20=C2=B7=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=99=95=EC=9D=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판 1 옮김 37종 · 옵션 235 · 검사 통과 · 읽기 누구나 · 쓰기 403 · 판 +1 · 이력 판_N · 낡은 판 409 - 검사 422 = 키 겹침 · 꼴 · 규칙 대상 · 배치 · 종류 덧칸 · 표시 · 그룹 형상 · 모르는 칸(안 씀) - 프로젝트 저장 정의 판 · 없는 키 값 보존 · ui_checks 48-1_구조물정의_읽기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../tester/test_m02_structure_definition.py | 158 ++++++++++++++++++ .../tester/ui_checks/48-1_구조물정의_읽기.js | 60 +++++++ 2 files changed, 218 insertions(+) create mode 100644 resources/tester/test_m02_structure_definition.py create mode 100644 resources/tester/ui_checks/48-1_구조물정의_읽기.js diff --git a/resources/tester/test_m02_structure_definition.py b/resources/tester/test_m02_structure_definition.py new file mode 100644 index 000000000..d4df44197 --- /dev/null +++ b/resources/tester/test_m02_structure_definition.py @@ -0,0 +1,158 @@ +"""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() + assert doc["판"] == 1 and len(doc["types"]) == 37 + assert sum(len(t["options"]) for t in doc["types"]) == 235 + assert len(load_structure_types()) == 37 + assert sum(len(t.options) for t in load_structure_types()) == 235 + assert definition.check(doc) == [] + 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) + assert got.status_code == 200 and got.json()["판"] == 1 + assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀 + + +def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + tid = doc["types"][0]["type_id"] + doc["types"][0]["name"] = "이름 바꿈" + res = admin.put(URL, json={"판": 1, "정의": doc}) + assert res.status_code == 200, res.text + assert res.json()["판"] == 2 + old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8")) + assert old["판"] == 1 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) == 37 + assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판 + assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3 + assert (copy.parent / "구조물정의_이력" / "판_2.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() + 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={"판": 1, "정의": doc}) + assert res.status_code == 422, (i, res.text) + assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + assert not (copy.parent / "구조물정의_이력").exists() + shaped = bad(종류={tid: {"기준측점": False}}) + shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}} + assert admin.put(URL, json={"판": 1, "정의": 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"] == 1 + assert load_structures(root)[1][0].options["지난정의키"] == "옛값" diff --git a/resources/tester/ui_checks/48-1_구조물정의_읽기.js b/resources/tester/ui_checks/48-1_구조물정의_읽기.js new file mode 100644 index 000000000..306517a2f --- /dev/null +++ b/resources/tester/ui_checks/48-1_구조물정의_읽기.js @@ -0,0 +1,60 @@ +/* 48-1 — 구조물 정의 한 벌(resources/master_template/구조물정의.json)로 옮긴 뒤 화면 변화 0 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자 · 저장 안 함 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition) + * - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] } + * (GET /api/projects/structure-types — 옛 꼴 그대로) + * - 좌측 「구조물 옵션 보이기」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 옛 화면 그대로) + * · pipe 단추에 그룹 표지 「3」 + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const get = async (url) => { + const r = await fetch(url, { credentials: "include" }); + return r.ok ? r.json() : { status: r.status }; + }; + const out = {}; + const def = await get("/api/m02/structure-definition"); + if (!def.types) return JSON.stringify({ error: "정의 못 읽음", def }); + out.def = { + 판: def.판, + types: def.types.length, + options: def.types.reduce((n, t) => n + t.options.length, 0), + }; + const st = await get("/api/projects/structure-types"); + const pipe = st.types.find((t) => t.type_id === "pipe"); + out.types = { + types: st.types.length, + options: st.types.reduce((n, t) => n + t.options.length, 0), + visibleAll: st.types.every((t) => t.options.every((o) => typeof o.visible === "boolean")), + pipeGroups: pipe.groups.map((g) => g.name), + }; + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ ...out, error: "옵션 보이기 줄 없음" }); + row.click(); + await until(() => $$("[data-options-kind]").length); + const kinds = $$("[data-options-kind]"); + out.screen = { + kinds: kinds.length, + withOptions: def.types.filter((t) => t.options.length).length, + pipeTag: $('[data-options-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null, + }; + out.ok = + out.def.types === 37 && + out.def.options === 235 && + out.types.types === 37 && + out.types.options === 235 && + out.types.pipeGroups.length === 3; + return JSON.stringify(out); +})();