feat(M02): 48-1 구조물 정의 한 벌 — 정의 틀 문서 · 판 1 옮김 · 읽기 길

- 정의 틀 = docs/raw/M02_구조물옵션정의/정의_틀.md (담는 것 · 키 규칙 · 꼴 · 배치 · 그룹 형상 · 규칙 · 동작 · 판 이력)
- resources/master_template/구조물정의.json 판 1 = 옛 B05 레지스트리(37종 · 옵션 235) + 옵션표시 + 옵션그룹 키 그대로 옮김
- GET /api/m02/structure-definition(로그인) · PUT(시스템 관리자만 · 판 +1 · 이력 판_N) · 검사(꼴 · 키 겹침 · 규칙 대상)
- GET /structure-types · B05 구조물 검사가 새 정의를 읽음(응답 꼴 그대로) · 옵션 보이기 · 그룹 저장도 정의 칸으로
- 프로젝트 structures.json 에 저장 때 정의 판 · 정의에 없는 키 값은 남김(오류 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-05 16:09:33 +09:00
co-authored by Claude Opus 5.5
parent 4600b9970c
commit bf1fa50817
22 changed files with 1194 additions and 285 deletions
@@ -1,23 +0,0 @@
{
"pipe": [
{
"id": "grv6lny",
"이름": "관",
"옵션": [
"pipe_kind",
"pipe_diameter_mm",
"inlet_type"
]
},
{
"id": "gnbf9m9",
"이름": "관 보호공(유입)",
"옵션": []
},
{
"id": "g9lq9uk",
"이름": "관 보호공(유출)",
"옵션": []
}
]
}
@@ -1 +0,0 @@
{}
File diff suppressed because it is too large Load Diff
@@ -192,10 +192,11 @@ def test_placement_mismatching_registry_is_rejected(project_root):
save_structures(project_root, [wall_as_point], base_revision=0)
def test_undefined_option_key_is_rejected(project_root):
bad = _point(options={"form": "돌", "no_such_option": 1})
with pytest.raises(ValueError, match="옵션"):
save_structures(project_root, [bad], base_revision=0)
def test_undefined_option_key_is_kept(project_root):
"""지금 정의에 없는 키 = 지난 정의 값 — 오류 없이 그대로 남김(PLAN 48-1)."""
old = _point(options={"form": "돌", "no_such_option": 1})
assert save_structures(project_root, [old], base_revision=0) == 1
assert load_structures(project_root)[1][0].options["no_such_option"] == 1
def test_negative_number_option_is_rejected(project_root):
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
def _box_options() -> dict[str, dict]:
registry = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
types = registry if isinstance(registry, list) else registry.get("types") or []
box = next(t for t in types if t["type_id"] == "box_culvert")
+1 -1
View File
@@ -32,7 +32,7 @@ QUANTITY_ONLY = {
def _pipe_option_keys() -> list[str]:
payload = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
types = payload if isinstance(payload, list) else payload["types"]
pipe = next(item for item in types if item["type_id"] == "pipe")
@@ -20,7 +20,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
REGISTRY = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
+1 -1
View File
@@ -30,7 +30,7 @@ KNOWN_ABSENT: dict[str, str] = {}
def _registry_keys() -> set[str]:
payload = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
types = payload if isinstance(payload, list) else (payload.get("types") or [])
return {option["key"] for item in types for option in item.get("options") or []}
+1 -1
View File
@@ -90,7 +90,7 @@ def test_사방_종류_이름이_레지스트리와_맞을것() -> None:
import json
registry = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
types = registry if isinstance(registry, list) else (registry.get("types") or [])
known = {t["type_id"] for t in types}
@@ -68,7 +68,7 @@ def test_바닥막이는_제곱미터당() -> None:
def test_개거_L형수로는_등록부_글자로_선다() -> None:
registry = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
choices = next(
option["choices"]
+1 -1
View File
@@ -29,7 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
def _registry_choices() -> list[str]:
payload = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
)
types = payload if isinstance(payload, list) else payload["types"]
pipe = next(item for item in types if item["type_id"] == "pipe")
@@ -1,12 +1,11 @@
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2).
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · 정의 「그룹」 칸 · PLAN 46-2).
못박는 것
- 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
- 「그룹」 이 `{}` 면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
- 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감.
- 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부).
- 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 ·
거절되면 안 씀.
- 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500.
- 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드).
"""
@@ -19,10 +18,10 @@ 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_Router import router as b05_router
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
ROOT = Path(__file__).resolve().parents[2]
@@ -32,7 +31,12 @@ TYPES = "/api/projects/structure-types"
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
monkeypatch.setattr(store, "FOLDER", tmp_path)
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
copy = tmp_path / "구조물정의.json"
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
app = FastAPI()
app.include_router(m02_router)
app.include_router(b05_router)
@@ -48,11 +52,9 @@ def _groups(client: TestClient) -> dict[str, list[dict]]:
return {t["type_id"]: t["groups"] for t in client.get(TYPES).json()["types"]}
def test_시드는_빈_표_없으면_그룹_없음(client) -> None:
seed = ROOT / "resources/master_template/구조물옵션그룹.json"
assert json.loads(seed.read_text("utf-8")) == {}
def test_빈_그룹_칸은_그룹_없음(client) -> None:
got = client.get(URL).json()
assert got["판"] == "" and got["그룹"] == {}
assert got["판"] == "1" and got["그룹"] == {}
assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도
assert all(v == [] for v in _groups(client).values())
@@ -67,7 +69,7 @@ def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) ->
],
empty: [{"id": "gx", "이름": "부속", "옵션": []}],
}
res = client.put(URL, json={"판": "", "그룹": body})
res = client.put(URL, json={"판": "1", "그룹": body})
assert res.status_code == 200, res.text
assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움
seen = _groups(client)
@@ -92,20 +94,14 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
{tid: [ok, {"id": "g2", "이름": "나", "옵션": [a]}]}, # 옵션 두 그룹
{tid: {"g1": ok}},
):
assert client.put(URL, json={"판": "", "그룹": bad}).status_code == 422, bad
assert not (store.FOLDER / groups.FILE).exists() # 거절되면 안 씀
v1 = client.put(URL, json={"판": "", "그룹": {tid: [ok]}}).json()["판"]
assert client.put(URL, json={"판": "1", "그룹": bad}).status_code == 422, bad
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
v1 = client.put(URL, json={"판": "1", "그룹": {tid: [ok]}}).json()["판"]
assert client.put(URL, json={"판": "낡음", "그룹": {}}).status_code == 409
res = client.put(URL, json={"판": v1, "그룹": {}})
assert res.status_code == 200 and res.json()["그룹"] == {}
def test_파일이_깨져도_폼_응답은_그룹_없음(client) -> None:
(store.FOLDER / groups.FILE).write_text("{깨짐", encoding="utf-8")
assert all(v == [] for v in _groups(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 "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side
+19 -25
View File
@@ -1,10 +1,9 @@
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정의 「표시」 칸 · PLAN 48-1).
못박는 것
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
- 「표시」 가 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
"""
from __future__ import annotations
@@ -16,10 +15,10 @@ 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_Router import router as b05_router
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions import M02_SO_Options as opt
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
ROOT = Path(__file__).resolve().parents[2]
@@ -33,7 +32,12 @@ def structure_type(type_id):
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
monkeypatch.setattr(store, "FOLDER", tmp_path)
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
copy = tmp_path / "구조물정의.json"
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
app = FastAPI()
app.include_router(m02_router)
app.include_router(b05_router)
@@ -51,13 +55,9 @@ def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
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"))
== {}
)
def test_빈_표시는_모두_보임(client) -> None:
got = client.get(URL).json()
assert got["판"] == "" and got["표시"] == {} # 파일 없음
assert got["판"] == "1" and got["표시"] == {}
assert all(all(v.values()) for v in _visible(client).values())
assert client.get(TYPES).json()["schema_version"] == 1
@@ -65,7 +65,7 @@ def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
tid, off, on = _pick()
before = client.get(TYPES).json()["types"]
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
res = client.put(URL, json={"판": "1", "표시": {tid: {off: False, on: True}}})
assert res.status_code == 200, res.text
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
seen = _visible(client)
@@ -83,10 +83,10 @@ def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client)
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
tid, off, _ = _pick()
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
v1 = client.put(URL, json={"판": "1", "표시": {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 json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["표시"] == {}
assert all(all(v.values()) for v in _visible(client).values())
@@ -98,21 +98,15 @@ def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) ->
{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={"판": "1", "표시": bad}).status_code == 422, bad
assert client.put(URL, json={"판": "1", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"]
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
assert client.put(URL, json={"판": "1", "표시": {}}).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
@@ -165,7 +159,7 @@ def test_숨긴_옵션은_필수_아님_저장이_안_막힘(client, tmp_path, m
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")
monkeypatch.setattr(repo, "hidden_keys", lambda: {"fake_hide": {"a"}})
with pytest.raises(ValueError, match="필수"): # a 는 숨겨 통과 · b 는 여전히 필수
repo.save_structures(root, [item({})], base_revision=0)
assert (