feat(B05): 숨긴 옵션은 필수 아님 — 저장 검증이 M02 옵션 보이기 표를 봄 · 시험 한 건
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
This commit is contained in:
@@ -14,6 +14,7 @@ from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys
|
||||
|
||||
STRUCTURES_FILE_NAME = "structures.json"
|
||||
_STAGE_DIR = os.path.join("B05_Profile", "route")
|
||||
@@ -168,6 +169,7 @@ def _validate_types(items: list[StructureInstance]) -> None:
|
||||
들어간다(2026-08-16 크로스체크 지적 2). 정본에 닿는 마지막 관문은 여기다.
|
||||
"""
|
||||
types = structure_type_map()
|
||||
hidden = hidden_keys() # M02 「구조물 옵션 보이기」 — 숨긴 옵션은 필수 아님
|
||||
for item in items:
|
||||
definition = types.get(item.type_id)
|
||||
if definition is None:
|
||||
@@ -182,10 +184,10 @@ def _validate_types(items: list[StructureInstance]) -> None:
|
||||
f"{definition.name}의 배치형태는 {definition.placement}인데 "
|
||||
f"{item.placement}로 보냈습니다."
|
||||
)
|
||||
_validate_options(item, definition)
|
||||
_validate_options(item, definition, hidden.get(item.type_id, set()))
|
||||
|
||||
|
||||
def _validate_options(item: StructureInstance, definition) -> None:
|
||||
def _validate_options(item: StructureInstance, definition, hidden: set[str] = frozenset()) -> None:
|
||||
allowed = {option.key: option for option in definition.options}
|
||||
for key, value in item.options.items():
|
||||
option = allowed.get(key)
|
||||
@@ -203,6 +205,8 @@ def _validate_options(item: StructureInstance, definition) -> None:
|
||||
# 강제하지 않는다. 필수 원칙은 유지되고 시점만 미뤄진다(2026-08-17 사용자 확정).
|
||||
if option.phase == "detail":
|
||||
continue
|
||||
if option.key in hidden: # 화면에서 숨긴 옵션은 값을 못 넣으므로 필수로 안 침
|
||||
continue
|
||||
if option.required and item.options.get(option.key) in (None, ""):
|
||||
raise ValueError(
|
||||
f"{definition.name}의 {option.label}은(는) 필수 입력입니다 — "
|
||||
|
||||
@@ -65,6 +65,20 @@ def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
return not (isinstance(row, dict) and row.get(key) is False)
|
||||
|
||||
|
||||
def hidden_keys() -> dict[str, set[str]]:
|
||||
"""숨긴 옵션(종류 → 키 집합) — 저장 검증이 숨긴 옵션을 필수로 안 침 · 깨진 표는 모두 보임."""
|
||||
try:
|
||||
doc = _load()[0]
|
||||
except store.StoreError as e:
|
||||
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 봄 — %s", e.detail)
|
||||
return {}
|
||||
return {
|
||||
type_id: {key for key, shown in row.items() if shown is False}
|
||||
for type_id, row in doc.items()
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
|
||||
|
||||
def with_visible() -> TypesWithVisible:
|
||||
"""레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만)."""
|
||||
try:
|
||||
|
||||
@@ -21,8 +21,8 @@ const TEXT = {
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Required: ["필수", "Required"],
|
||||
RequiredTip: [
|
||||
"필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음",
|
||||
"Required — hiding it leaves the B05 form unable to set it",
|
||||
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
|
||||
"Required — when hidden it is not shown and not enforced",
|
||||
],
|
||||
Save: ["저장", "Save"],
|
||||
ShowAll: ["모두 보임", "Show all"],
|
||||
|
||||
@@ -27,6 +27,10 @@ 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)
|
||||
@@ -127,3 +131,38 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
)
|
||||
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 는 빈 채 저장
|
||||
|
||||
Reference in New Issue
Block a user