"""저장 제원 키 ↔ 엔진이 읽는 키 대조 (2026-09-07 ⑳ 앞). ⚠ **오늘 잡은 사고 중 사용자 입력이 직접 무시되던 첫 사례**가 여기였다 — 레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어 **뒷길이를 75 로 골라도 늘 45 계수**로 돌았다. 값이 나오므로 아무 시험도 안 잡았다. ⇒ 「이름으로 알아보는 코드는 정본과 대조하라」(사방공 `type_id` 건)의 **데이터 키 판**이다. """ from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) #: 레지스트리에 칸이 **아직 없는** 키. 「칸이 생기면 받는다」는 뜻으로 코드에 남겨 둔 것이고, #: 키 이름이 어긋난 것과는 다르다. 새로 늘리려면 **왜 없는지**를 여기 적을 것. #: 레지스트리에 아직 칸이 없는 키 — **왜 없는지**를 함께 적는다. #: ⚠ 2026-09-09 `face_slope_ratio` 는 칸이 생겨 뺐음(`ebdf2988`). 비워 두면 품셈 #: 표준경사표로 자동 판정되고, 값이 있으면 그 값이 이긴다(확정 ⑨). #: ⓘ 2026-09-09 오후 비었다 — `ditch_spec` 은 랩탑 보조가 칸을 만들어(`92fd9092`) 여기서 뺐다. #: 목록이 비어 있는 것이 정상이다. **칸 없이 코드에만 있는 키가 생기면** 여기 까닭과 함께 #: 적을 것 — 적지 않으면 이 시험이 「이름을 잘못 적었다」로 잡는다. 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") ) 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 []} def _engine_keys() -> dict[str, set[str]]: found: dict[str, set[str]] = {} for path in (ROOT / "B08_Quantity").glob("*.py"): text = path.read_text(encoding="utf-8") for match in re.finditer(r'options\.get\(\s*"([a-z0-9_]+)"', text): found.setdefault(match.group(1), set()).add(path.name) for path in (ROOT / "resources").rglob("*.json"): try: text = path.read_text(encoding="utf-8") except OSError: continue for match in re.finditer(r'"option_key"\s*:\s*"([a-z0-9_]+)"', text): found.setdefault(match.group(1), set()).add(path.name) return found def test_엔진이_읽는_키가_레지스트리에_있을것() -> None: """⚠ 이 시험이 `back_len_cm` 사고를 다시 못 나게 한다.""" registry = _registry_keys() missing = { key: sorted(where) for key, where in _engine_keys().items() if key not in registry and key not in KNOWN_ABSENT } assert not missing, f"레지스트리에 없는 옵션 키를 읽고 있다: {missing}" def test_없는_키는_사유가_적혀_있을것() -> None: """「없다」를 그냥 넘기지 않는다 — 왜 없는지가 적혀 있어야 한다.""" for key, why in KNOWN_ABSENT.items(): assert why, key # 사유 목록에 적힌 키가 정말 레지스트리에 없는지도 본다(고쳐졌는데 목록만 남는 것 방지). registry = _registry_keys() stale = [key for key in KNOWN_ABSENT if key in registry] assert not stale, f"레지스트리에 칸이 생겼으니 목록에서 뺄 것: {stale}" def test_돌쌓기_뒷길이_키가_실제로_맞을것() -> None: """사고가 났던 그 자리를 이름으로 직접 못 박는다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS assert "back_len_cm" in BACK_LENGTH_KEYS assert "back_len_cm" in _registry_keys() def test_되돌릴_수_있어야_하는_숫자_칸은_계약에_등록될것() -> None: """⚠ `None` 을 통째로 버리면 「안 정함」으로 되돌릴 길이 없다 (2026-09-08 ㉘). 시공법·타설 방식은 빈 문자열로 되돌리지만 **숫자 칸은 되돌릴 값이 `None` 뿐**이다. 새 숫자 칸을 넣고 여기 등록하지 않으면 **한 번 넣은 값이 영영 남는다.** 같은 병을 두 번 만났으므로 계약처럼 다룬다. """ from B08_Quantity.B08_Quantity_Router_Earthwork import ( NULLABLE_SETTING_KEYS, QuantitySettingsBody, ) # ⚠ 좁게 본다 — `dict[str, float]`(비율표)까지 걸면 정상 칸을 잡는다(넓은 규칙 사고). # 여기서 겨냥하는 것은 **홑 숫자 칸**뿐이다. 홑숫자 = {float | None, int | None} 숫자칸 = { name for name, field in QuantitySettingsBody.model_fields.items() if field.annotation in 홑숫자 } assert 숫자칸, "홑 숫자 칸을 하나도 못 골랐다 — 판정이 틀렸다" 빠진것 = 숫자칸 - set(NULLABLE_SETTING_KEYS) assert not 빠진것, f"숫자 칸이 NULLABLE_SETTING_KEYS 에 없다 — 되돌릴 길이 없다: {sorted(빠진것)}" def test_되돌리기_칸은_갈아끼우기_대상이기도_할것() -> None: """등록만 하고 갈아끼우기(`replace_keys`)에서 빠지면 병합돼 옛 값이 남는다.""" import inspect from B08_Quantity import B08_Quantity_Router_Earthwork as router 본문 = inspect.getsource(router.save_quantity_settings) assert "NULLABLE_SETTING_KEYS" in 본문