- M02_SO_Def_Check.ts ↔ M02_SO_Check.py 글 · test_322_2_check_mirror 기대 글 - B05 3D: buildCorridorStructures 관 몸체 = 같은 m 앞 카드만 · 벽은 그대로(wallSweepAt) - 시험: test_323_9_twin_cards_3d(세월교 · BOX 카드 둘 = 하나와 같음 · 고치기 전 두 배) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
251 lines
11 KiB
Python
251 lines
11 KiB
Python
"""PLAN 322-2 구조물 표 화면 — 검사 짝(화면 = 서버) · 반영 손질 · [저장] 길 · 명세 폼 숨김.
|
|
|
|
- 거울: 검사 견본마다 TS `catalogProblems` = 파이썬 `catalog_errors`(글까지 같음).
|
|
- 1-3 규칙: 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 부품이 읽는 칸 새로 숨김 금지 ·
|
|
만든 뒤 고정(자리꼴 · 정본 · 군 글자 · 종류 지우기 · 선택지 값).
|
|
- 반영 손질: 처음 값이면 반영에서 지움 · 목록 + enabled(both 일 때만 참) · 형상칸 · 보임 · 부품 켬끔.
|
|
- 서버 [저장]: 반영 · 부품 · 알약 색 · enabled 를 받아 판 +1 · 표(`catalog()`)가 바로 따름 · 틀리면 422.
|
|
- 명세 폼 숨김: 칸 = 쓰는 옵션 키가 모두 숨김 · 상자 = 키 있는 칸이 모두 숨김(지금 정의로는 숨는 칸 없음).
|
|
TS 쪽 = `helper_322_2_check.cjs`(Node) · 마스터 정의는 읽기만(사본으로 고침).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import subprocess
|
|
import tempfile
|
|
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 common_util.common_util_structure_catalog import catalog
|
|
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
|
|
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
|
|
|
|
HERE = Path(__file__).parent
|
|
ROOT = HERE.parent.parent
|
|
URL = "/api/m02/structure-definition"
|
|
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
|
)
|
|
|
|
|
|
def _real() -> dict:
|
|
return json.loads(Path(schema.DEFINITION_PATH).read_text(encoding="utf-8"))
|
|
|
|
|
|
def _type(doc: dict, type_id: str) -> dict:
|
|
return next(t for t in doc["types"] if t["type_id"] == type_id)
|
|
|
|
|
|
def _cases() -> dict[str, tuple[dict, dict | None]]:
|
|
real = _real()
|
|
cases: dict[str, tuple[dict, dict | None]] = {"real": (real, real)}
|
|
bad = copy.deepcopy(real)
|
|
bad["반영"] = {
|
|
"nope": {},
|
|
"pipe": {
|
|
"모름": 1,
|
|
"목록": "x",
|
|
"횡단도": "zz",
|
|
"형상": 3,
|
|
"형상칸": {"form": " "},
|
|
"3D": "none",
|
|
},
|
|
}
|
|
bad["부품"] = {"형상.zz": False, "횡단도.span3": "no", "끔": False}
|
|
cases["shape_bad"] = (bad, real)
|
|
rule = copy.deepcopy(real)
|
|
rule["반영"] = {"open_ditch": {"횡단도": "anchor"}}
|
|
cases["rule218"] = (rule, real)
|
|
mismatch = copy.deepcopy(real)
|
|
mismatch["반영"] = {"retaining_wall": {"형상": "pipe_set"}, "berm": {"횡단도": "anchor"}}
|
|
cases["mismatch"] = (mismatch, real)
|
|
hide = copy.deepcopy(real)
|
|
hide["표시"]["pipe"]["revet_foundation"] = False # 관 세트가 읽는 기본값 없는 칸 새로 숨김
|
|
hide["표시"]["retaining_wall"]["height_m"] = False # 기본값 있는 칸 = 숨겨도 됨(늘 기본값)
|
|
cases["hide_read"] = (hide, real)
|
|
cases["hide_no_before"] = (hide, None)
|
|
fixed = copy.deepcopy(real)
|
|
_type(fixed, "box_culvert")["placement"] = "interval"
|
|
_type(fixed, "revetment")["managed_by"] = None
|
|
_type(fixed, "mirror")["group"] = "F"
|
|
dia = next(o for o in _type(fixed, "pipe")["options"] if o["key"] == "pipe_diameter_mm")
|
|
dia["choices"] = [*dia["choices"], "2000"]
|
|
fixed["types"] = [t for t in fixed["types"] if t["type_id"] != "etc"]
|
|
cases["fixed"] = (fixed, real)
|
|
off = copy.deepcopy(real)
|
|
off["부품"] = {"형상.wall": False}
|
|
cases["parts_off"] = (off, real)
|
|
drain = copy.deepcopy(real)
|
|
drain["부품"] = {"형상.underdrain": False}
|
|
cases["underdrain_off"] = (drain, real)
|
|
return cases
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ts() -> dict:
|
|
payload = {"cases": _cases(), "definition": _real()}
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
path = Path(folder) / "input.json"
|
|
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
out = subprocess.run(
|
|
["node", str(HERE / "helper_322_2_check.cjs"), str(path)],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=120,
|
|
check=True,
|
|
)
|
|
return json.loads(out.stdout)
|
|
|
|
|
|
def test_check_mirror_screen_equals_server(ts: dict) -> None:
|
|
for name, (doc, before) in _cases().items():
|
|
assert catalog_errors(doc, before) == ts["cases"][name], name
|
|
|
|
|
|
def test_check_rules(ts: dict) -> None:
|
|
got = ts["cases"]
|
|
assert got["real"] == []
|
|
assert got["shape_bad"] == [
|
|
"반영 「nope」 — 없는 종류이거나 묶음 아님",
|
|
"반영 「pipe.모름」 — 모르는 칸",
|
|
"반영 「pipe.목록」 — both · b06 · hidden 중 하나",
|
|
"반영 「pipe.횡단도」 — 없는 부품 「zz」",
|
|
"반영 「pipe.형상」 — 없는 부품 「3」",
|
|
"반영 「pipe.형상칸」 — form 글 · weep 참거짓",
|
|
"부품 「형상.zz」 — 없는 부품",
|
|
"부품 「횡단도.span3」 — 참거짓",
|
|
"부품 「끔」 — 없는 부품",
|
|
]
|
|
assert got["rule218"] == ["「개거(겉도랑)」 형상이 「없음」이면 횡단도도 「안 만듦」"]
|
|
assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물에 안 맞음"]
|
|
assert got["hide_read"] == [
|
|
"「배관」 형상 「배관 세트」 이 쓰는 칸 「흙막이 기초」 — 숨길 수 없음"
|
|
]
|
|
assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음
|
|
assert got["fixed"] == [
|
|
# 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음
|
|
"「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음",
|
|
"「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음",
|
|
"「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물에 안 맞음",
|
|
"「배관」 「관경」 선택지 값 — 고칠 수 없음(이름만 고침)",
|
|
"「BOX암거」 자리꼴 — 만든 뒤 고정",
|
|
"「기슭막이」 정본 — 만든 뒤 고정",
|
|
"「반사경」 군 글자 — 만든 뒤 고정",
|
|
"종류 「기타」 — 지울 수 없음",
|
|
]
|
|
# 형상 부품을 끄면 쓰는 종류 형상 none — 부품 이름 + 쓰는 종류 전부 한 줄(323-9)
|
|
walls = "옹벽, 돌쌓기(찰), 돌쌓기(메), 흙막이, 큰돌쌓기".split(", ")
|
|
assert got["parts_off"] == [
|
|
f"부품 「벽」 끔 — 이 부품을 쓰는 종류({', '.join(f'C 사면안정 · {w}' for w in walls)})의 "
|
|
"횡단도를 「안 만듦」으로 바꿔야 저장"
|
|
]
|
|
assert got["underdrain_off"] == [
|
|
"부품 「맹암거」 끔 — 이 부품을 쓰는 종류(A 횡단배수 · 맹암거)의 횡단도를 「안 만듦」으로 바꿔야 저장"
|
|
]
|
|
|
|
|
|
def test_reflect_edits(ts: dict) -> None:
|
|
r = ts["reflect"]
|
|
assert r["wildlife"] == [None, True] # b06 → both = enabled 참이 곧 처음 값 짝(반영 안 남음)
|
|
assert r["wall"] == [None, False] # both → b06 = enabled 거짓
|
|
assert r["openDitch"] is None # 처음 값(none)과 같음 — 반영 안 남음
|
|
assert r["exposed"] == [{"횡단도": "anchor"}, True]
|
|
assert r["weepOn"] == {"형상칸": {"weep": True}} and r["weepOff"] is None
|
|
assert r["shown"] == {"pipe_kind": False, "inlet_type": False} # 정의 옵션만
|
|
assert r["readPipe"] == ["inlet_basin_height_m", "revet_foundation"] # 기본값 없는 읽는 칸
|
|
assert r["wallUsers"] == [
|
|
"retaining_wall",
|
|
"masonry_wet",
|
|
"masonry_dry",
|
|
"soil_guard",
|
|
"boulder_masonry",
|
|
]
|
|
assert r["reset"] == [None, None, True]
|
|
|
|
|
|
def test_revert_means_nothing_to_save(ts: dict) -> None:
|
|
assert ts["revert"] == [True, False] # 바꾸면 저장할 것 · 되돌리면 없음
|
|
|
|
|
|
def test_save_writes_list_and_enabled_together(ts: dict) -> None:
|
|
sync = ts["sync"]
|
|
assert sync["same"] is True
|
|
rows = catalog()
|
|
assert set(sync["enabled"]) == {t for t, row in rows.items() if row["list"] == "both"}
|
|
assert len(sync["enabled"]) == 23 # B05 목록 23(브레인 화면 확인)
|
|
assert ts["partOff"] == [{"형상.wall": False}, "none"] and ts["partOn"] == {}
|
|
|
|
|
|
def test_spec_form_hidden_rule(ts: dict) -> None:
|
|
spec = ts["spec"]
|
|
assert spec["pipe"]["hiddenItems"] == ["pipe.diameter"]
|
|
assert spec["box_culvert"]["allBoxes"] == spec["box_culvert"]["keyedBoxes"]
|
|
assert all(s["noneHidden"] == 0 for s in spec.values())
|
|
assert spec["etc"]["keyedBoxes"] == [] # 선택 상자 인스턴스 키는 옵션 밖 — 숨김 대상 아님
|
|
|
|
|
|
@pytest.fixture
|
|
def copy_def(tmp_path: Path, monkeypatch: pytest.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() -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(read_router)
|
|
app.dependency_overrides[verify_session] = lambda: ADMIN
|
|
return TestClient(app)
|
|
|
|
|
|
def test_server_save_reflect_parts_color_enabled(copy_def: Path) -> None:
|
|
client = _client()
|
|
doc = client.get(URL).json()
|
|
sent = copy.deepcopy(doc)
|
|
sent["반영"] = {"open_ditch": {"횡단도": "anchor", "형상": "underdrain", "목록": "b06"}}
|
|
sent["부품"] = {"3D.chute": False}
|
|
_type(sent, "mirror")["style"]["color"] = "#123456"
|
|
_type(sent, "masonry_wet")["enabled"] = False # 목록 hidden ⇒ enabled 거짓(화면이 함께 씀)
|
|
res = client.put(URL, json={"판": doc["판"], "정의": sent})
|
|
assert res.status_code == 200, res.text
|
|
got = res.json()
|
|
assert got["판"] == doc["판"] + 1
|
|
assert got["반영"] == sent["반영"] and got["부품"] == sent["부품"]
|
|
rows = catalog() # 파일 시각으로 바로 새 표
|
|
assert (rows["open_ditch"]["station"], rows["open_ditch"]["list"]) == ("anchor", "b06")
|
|
assert rows["ford_pavement"]["model3d"] == "none"
|
|
assert rows["mirror"]["color"] == "#123456"
|
|
assert rows["masonry_wet"]["list"] == "hidden"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"change",
|
|
[
|
|
lambda d: d.update(반영={"open_ditch": {"횡단도": "anchor"}}), # 218
|
|
lambda d: d["표시"].setdefault("pipe", {}).update(revet_foundation=False), # 못 숨김 칸
|
|
lambda d: next(o for o in _type(d, "pipe")["options"] if o["key"] == "pipe_kind")[
|
|
"choices"
|
|
].append("새관"), # 선택지 값
|
|
lambda d: _type(d, "berm").update(placement="point"), # 자리꼴 고정
|
|
],
|
|
)
|
|
def test_server_rejects_rule_breaks(copy_def: Path, change) -> None:
|
|
client = _client()
|
|
doc = client.get(URL).json()
|
|
sent = copy.deepcopy(doc)
|
|
change(sent)
|
|
res = client.put(URL, json={"판": doc["판"], "정의": sent})
|
|
assert res.status_code == 422, res.text
|
|
assert json.loads(copy_def.read_text("utf-8"))["판"] == doc["판"]
|