- 「일위대가 조합」 · 「구조물 조합」 컨테이너 안 같은 이름 줄 단추 없앰 — 제목을 펼치면 그 화면을 엶 - 나무 맨 위 뿌리 줄 없앰 — 폴더 · 항목부터 · 맨 위 더하기는 빈 자리 오른쪽 클릭 · 층 단추 옆 「+」(읽기만 층 = 숨김) · 빈 나무 안내글 맞춤 - 찾기 칸 하나 — 띄어쓰기 낱말 모두(AND · 대소문자 무시) · 이름 · 키 · 경로(구분 · 상세구분) · 단위 · 공용 matchText 를 나무 · 일위대가 조합 목록이 함께 씀(목록의 따로 찾기 칸 없앰) - 시험 — 찾기 AND · 새 배치 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
8.2 KiB
Python
177 lines
8.2 KiB
Python
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
|
|
|
|
못박는 것
|
|
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
|
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
|
|
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
|
|
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
|
|
"""
|
|
|
|
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_Options as opt
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
URL = "/api/m02/structure-options"
|
|
TYPES = "/api/projects/structure-types"
|
|
|
|
|
|
def structure_type(type_id):
|
|
return next(t for t in load_structure_types() if t.type_id == type_id)
|
|
|
|
|
|
@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 _pick() -> tuple[str, str, str]:
|
|
"""옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키)."""
|
|
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 _visible(client: TestClient) -> dict[str, dict[str, bool]]:
|
|
types = client.get(TYPES).json()["types"]
|
|
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
|
|
|
|
|
|
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
|
assert (
|
|
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
|
|
== {}
|
|
)
|
|
got = client.get(URL).json()
|
|
assert got["판"] == "" and got["표시"] == {} # 파일 없음
|
|
assert all(all(v.values()) for v in _visible(client).values())
|
|
assert client.get(TYPES).json()["schema_version"] == 1
|
|
|
|
|
|
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
|
|
tid, off, on = _pick()
|
|
before = client.get(TYPES).json()["types"]
|
|
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
|
|
assert res.status_code == 200, res.text
|
|
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
|
|
seen = _visible(client)
|
|
assert seen[tid][off] is False and seen[tid][on] is True
|
|
assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로
|
|
after = client.get(TYPES).json()["types"]
|
|
strip = lambda ts: [ # noqa: E731
|
|
{**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]}
|
|
for t in ts
|
|
]
|
|
assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜
|
|
kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid)
|
|
assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0]
|
|
|
|
|
|
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
|
|
tid, off, _ = _pick()
|
|
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
|
res = client.put(URL, json={"판": v1, "표시": {}})
|
|
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
|
|
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
|
|
assert all(all(v.values()) for v in _visible(client).values())
|
|
|
|
|
|
def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None:
|
|
tid, off, _ = _pick()
|
|
for bad in (
|
|
{"없는종류": {off: False}},
|
|
{tid: {"없는키": False}},
|
|
{tid: {off: "no"}},
|
|
{tid: [off]},
|
|
):
|
|
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
|
|
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
|
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
|
|
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
|
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
|
|
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
|
assert client.get(URL).json()["판"] == v1
|
|
|
|
|
|
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
|
|
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
|
|
assert all(all(v.values()) for v in _visible(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 "section(options.title, options.body)" in side
|
|
# 차례 = 구조물 조합 → 옵션 → 도면 양식(시스템 층에만 · 해시 진입 `reveal` 이 이름으로 · 27-1d)
|
|
assert side.index("옵션: section(options.title") < side.index(
|
|
'도면: section(L("M02_KindDrawing")'
|
|
)
|
|
# 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식
|
|
order = [
|
|
"...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]"
|
|
" : []),",
|
|
" summary,",
|
|
" ...(extras ? [extras.옵션, extras.도면] : []),",
|
|
]
|
|
assert "\n".join(order) in side
|
|
# 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움
|
|
assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side
|
|
assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side
|
|
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
|
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
|
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
|
assert (
|
|
'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api
|
|
)
|
|
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
|
|
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
|
|
|
|
|
def test_숨긴_옵션은_필수_아님_저장이_안_막힘(client, tmp_path, monkeypatch) -> None:
|
|
"""B05 필수(기본값 없음) 옵션 — 보이면 막고 숨기면 저장됨(다른 필수는 그대로)."""
|
|
from B05_Profile import B05_Profile_Structures_Repository as repo
|
|
from B05_Profile.B05_Profile_Structures_Schema import (
|
|
StructureInstance,
|
|
StructureOptionField,
|
|
StructureType,
|
|
)
|
|
|
|
def must(key):
|
|
return StructureOptionField(key=key, label=key, input="number", required=True, phase="b05")
|
|
|
|
fake = StructureType(
|
|
type_id="fake_hide",
|
|
group="Z",
|
|
name="가짜",
|
|
placement="point",
|
|
options=[must("a"), must("b")],
|
|
)
|
|
real = repo.structure_type_map()
|
|
monkeypatch.setattr(repo, "structure_type_map", lambda: {**real, "fake_hide": fake})
|
|
item = lambda opts: StructureInstance.model_validate( # noqa: E731
|
|
{"type_id": "fake_hide", "placement": "point", "chainage_m": 10.0, "options": opts}
|
|
)
|
|
root = str(tmp_path / "project")
|
|
with pytest.raises(ValueError, match="필수"):
|
|
repo.save_structures(root, [item({})], base_revision=0) # 보임 = 필수 그대로
|
|
(store.FOLDER / opt.FILE).write_text('{"fake_hide": {"a": false}}', encoding="utf-8")
|
|
with pytest.raises(ValueError, match="필수"): # a 는 숨겨 통과 · b 는 여전히 필수
|
|
repo.save_structures(root, [item({})], base_revision=0)
|
|
assert (
|
|
repo.save_structures(root, [item({"b": 3})], base_revision=0) == 1
|
|
) # 숨긴 a 는 빈 채 저장
|