feat(M02): 48-2 지난 판 목록 · 한 벌 읽기 길 · 정의 화면 시험
- GET /api/m02/structure-definition/history · /history/{판}(로그인 누구나 · 없는 판 404)
- 화면이 만드는 고침 한 벌을 서버가 받음 · 판 +1 · 403 · 422 시험(사본 정의)
- 옛 옵션 보이기 · 그룹 시험의 화면 글 검사를 정의 화면 자리로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -7,7 +7,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -32,6 +34,26 @@ def history_path(version: int) -> Path:
|
||||
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
|
||||
|
||||
|
||||
def history() -> list[dict[str, Any]]:
|
||||
"""지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지)."""
|
||||
folder = history_path(1).parent
|
||||
rows = []
|
||||
for path in folder.glob("판_*.json") if folder.is_dir() else []:
|
||||
number = path.stem.removeprefix("판_")
|
||||
if number.isdigit():
|
||||
when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
rows.append({"판": int(number), "저장일": when})
|
||||
return sorted(rows, key=lambda r: r["판"], reverse=True)
|
||||
|
||||
|
||||
def read_version(version: int) -> dict[str, Any]:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
path = history_path(version)
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, f"판 {version} 이력이 없음")
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
|
||||
|
||||
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
|
||||
errors: list[str] = []
|
||||
|
||||
@@ -84,6 +84,20 @@ def get_structure_definition(session=Depends(verify_session)) -> dict:
|
||||
return _call(definition.read)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history")
|
||||
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
|
||||
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.history)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history/{version}")
|
||||
def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read_version, version)
|
||||
|
||||
|
||||
@read_router.put("/structure-definition")
|
||||
def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict:
|
||||
"""정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력."""
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""M02 구조물 정의 편집 화면(PLAN 48-2) — 지난 판 길 · 화면이 보내는 꼴을 서버가 받음.
|
||||
|
||||
못박는 것
|
||||
- 지난 판 목록 `[{판, 저장일}]` 새 판부터 · 한 벌 읽기 · 없는 판 404 · 읽기는 누구나.
|
||||
- 화면이 고친 정의(이름 · 순서 · 안 씀 · 기준측점 · 선택지 이름 · 배치 · 그룹 형상 · 규칙)를
|
||||
그대로 PUT 하면 판 +1 · 같은 값으로 다시 읽힘.
|
||||
- 시스템 관리자 아니면 PUT 403(화면은 읽기만).
|
||||
- 마스터 정본은 안 씀(사본).
|
||||
"""
|
||||
|
||||
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 common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
|
||||
|
||||
URL = "/api/m02/structure-definition"
|
||||
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.dependency_overrides[verify_session] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _screen_edit(doc: dict) -> dict:
|
||||
"""화면이 하는 고침 한 벌 — `M02_SO_Def*.ts` 가 만드는 꼴 그대로."""
|
||||
pipe = next(t for t in doc["types"] if t["type_id"] == "pipe")
|
||||
pipe["name"] = "배수관(고침)"
|
||||
kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind")
|
||||
kind["label"] = "관 종류"
|
||||
kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]]
|
||||
pipe["options"].append(
|
||||
{
|
||||
"key": "opt_ab12cd",
|
||||
"label": "새 옵션 1",
|
||||
"input": "select",
|
||||
"choices": ["새 선택지 1", "새 선택지 2"],
|
||||
"default": None,
|
||||
}
|
||||
)
|
||||
pipe["options"][1]["enabled"] = False # 안 씀
|
||||
first = list(doc["구조물군"])
|
||||
doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]}
|
||||
doc["종류"].setdefault("pipe", {})["기준측점"] = False
|
||||
doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}}
|
||||
doc["표시"]["pipe"] = {"inlet_type": False}
|
||||
doc["그룹"]["pipe"][1]["형상"] = {
|
||||
"길이": {"받기": True, "뜻": "유입 보호공 연장"},
|
||||
"높이": {"받기": False, "뜻": ""},
|
||||
}
|
||||
doc["규칙"] = [
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]},
|
||||
"그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"},
|
||||
},
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "inlet_type", "값들": ["집수정"]},
|
||||
"그러면": {"옵션": "opt_ab12cd", "보이기": False},
|
||||
},
|
||||
]
|
||||
return doc
|
||||
|
||||
|
||||
def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
doc = admin.get(URL).json()
|
||||
assert admin.get(f"{URL}/history").json() == []
|
||||
sent = _screen_edit(json.loads(json.dumps(doc)))
|
||||
res = admin.put(URL, json={"판": doc["판"], "정의": sent})
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["판"] == doc["판"] + 1
|
||||
for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"):
|
||||
assert got[key] == sent[key], key
|
||||
assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로
|
||||
rows = admin.get(f"{URL}/history").json()
|
||||
assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16
|
||||
old = admin.get(f"{URL}/history/{doc['판']}").json()
|
||||
assert old == doc
|
||||
assert admin.get(f"{URL}/history/999").status_code == 404
|
||||
|
||||
|
||||
def test_지난_판은_누구나_읽고_쓰기는_시스템_관리자만(copy) -> None:
|
||||
user = _client(USER)
|
||||
doc = user.get(URL).json()
|
||||
assert user.get(f"{URL}/history").status_code == 200
|
||||
assert user.put(URL, json={"판": doc["판"], "정의": _screen_edit(doc)}).status_code == 403
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
|
||||
|
||||
|
||||
def test_화면이_못_거른_틀린_규칙은_422_안_씀(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
doc = admin.get(URL).json()
|
||||
doc["규칙"] = [
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "pipe_kind", "값들": ["없음"]},
|
||||
"그러면": {"옵션": "inlet_type"},
|
||||
}
|
||||
]
|
||||
assert admin.put(URL, json={"판": doc["판"], "정의": doc}).status_code == 422
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
|
||||
@@ -102,8 +102,10 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
|
||||
assert res.status_code == 200 and res.json()["그룹"] == {}
|
||||
|
||||
|
||||
def test_좌측_옵션_보이기는_시스템_관리자만() -> None:
|
||||
def test_좌측_정의_줄은_시스템_층에_늘_고쳐_씀은_시스템_관리자만() -> None:
|
||||
"""PLAN 48-2 — 다른 역할은 읽기만(화면이 칸을 잠금 · 서버 PUT 403)."""
|
||||
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
|
||||
assert "옵션: section(options.title, options.body)," in side
|
||||
folder = ROOT / "M02_MasterTemplete/StructOptions"
|
||||
assert "buildGroupBlock(" in (folder / "M02_SO_Def.ts").read_text("utf-8")
|
||||
assert "buildPlaceSelect(" in (folder / "M02_SO_Def_Options.ts").read_text("utf-8")
|
||||
|
||||
@@ -123,12 +123,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
assert "matset" not in side
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
# 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄
|
||||
api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/layers/system/structure-options")' in api
|
||||
and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
|
||||
assert 'call("/structure-definition")' in api
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user