diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index e337c46d..30ae8a44 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -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}은(는) 필수 입력입니다 — " diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options.py b/M02_MasterTemplete/M02_MasterTemplete_Options.py index 8936080b..e5fb5514 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Options.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Options.py @@ -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: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts index 365d8c3a..206a0c35 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Options_Text.ts @@ -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"], diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index e856d406..fe9d9090 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -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 는 빈 채 저장