diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 4a492dcb..a25738ca 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -31,6 +31,10 @@ export interface StructureOptionField { /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ phase?: "b05" | "detail"; + /** **비워 두는 것이 뜻인 칸** — 비면 계산 쪽이 기준값으로 돌고 그 사실이 사유로 뜬다. + * ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이 + * 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */ + empty_means?: string | null; } /** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 019232c4..b106e926 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -325,9 +325,15 @@ export function createStructuresSection( const choices = option.choices.map((choice) => [choice, choice] as [string, string]); // 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다. const mustPick = option.required === true && (option.default ?? "") === ""; - if (mustPick) choices.unshift(["", "— 선택 —"]); + // **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데 + // 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13: + // `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은 + // 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다. + const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === ""; + const keepEmpty = mustPick || meansEmpty; + if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]); input = select(choices); - input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? ""))); + input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); diff --git a/resources/tester/test_b05_empty_means_select.py b/resources/tester/test_b05_empty_means_select.py new file mode 100644 index 00000000..54a0a7e6 --- /dev/null +++ b/resources/tester/test_b05_empty_means_select.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""구조물 폼의 **`empty_means` select 는 첫 보기를 박지 않는다** (2026-09-13). + +2026-08-17 지시로 select 에는 빈 보기를 두지 않았다 — 「첫 보기가 곧 기본값」이기 때문이다. +그런데 2026-09-09 에 셋째 갈래가 생겼다: **비워 두는 것이 뜻인 칸**(`empty_means`). 이 칸까지 +첫 보기를 박으니 **엔진 기준값과 다른 값이 조용히 저장**됐다. + +실측 사고 — `fill_concrete_mpa` 는 등록부 기본이 없고 「비우면 계산 쪽 기준 강도」인데 폼이 +「180」을 박아 저장했다. 엔진 기준은 확정 ⑩ 의 **210** 이라 채움콘크리트 규격·단가가 갈렸다. + +⚠ 기본값이 **있는** 칸은 그대로 첫 보기를 쓴다 — 2026-08-17 지시가 그 자리다. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8") +API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8") +COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( + encoding="utf-8" +) + + +def _registry_options(type_id: str) -> dict[str, dict]: + payload = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + item = next(entry for entry in payload["types"] if entry["type_id"] == type_id) + return {option["key"]: option for option in item["options"]} + + +def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None: + """소스 검사 — 빈 보기와 초기값이 **같은 판정**(`keepEmpty`)을 쓴다.""" + assert "option.empty_means" in PANEL + assert "const keepEmpty = mustPick || meansEmpty;" in PANEL + assert 'if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);' in PANEL + # 빈 보기만 넣고 초기값을 첫 보기로 두면 화면은 「안 정함」인데 값은 첫 보기가 된다. + assert 'String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")))' in PANEL + # 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다. + assert "empty_means?: string | null;" in API + # 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다. + assert "if (input.isEmpty()) return;" in COMMIT + + +def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: + """`empty_means` 가 적혀 있어도 **기본값이 있으면** 그 값이 뜬다(2026-08-17 지시 유지).""" + options = _registry_options("masonry_wet") + # 버림 콘크리트 — empty_means 가 있으나 기본이 「넣음」이다. + assert options["blinding_concrete"]["empty_means"] + assert options["blinding_concrete"]["default"] == "넣음" + # 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸. + for key in ("fill_concrete_mpa", "stone_coeff_basis"): + assert options[key]["empty_means"], key + assert options[key]["default"] is None, key + assert options[key]["required"] is False, key + assert '(option.default ?? "") === ""' in PANEL # 기본값 있으면 빈 보기를 안 둠 + + +def _wall(**options) -> dict: + return { + "structure_id": "w", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "start_m": 0.0, + "end_m": 10.0, + "length_m": 10.0, + "height_m": 2.5, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "stone_kind": "깬돌", + "foundation": "기초유", + **options, + }, + } + + +def _fill_row(**options) -> dict: + table = build_table([_wall(**options)], {"masonry_wet": "돌쌓기(찰)"}, {}) + rows = table["structures"][0]["components"] + return next(row for row in rows if row["name"] == "채움콘크리트") + + +def test_칸을_비우면_강도가_확정_210_으로_선다() -> None: + """⚠ 폼이 180 을 박던 그 자리 — 비면 **엔진 기준값**이 서고 그 사실이 근거에 남는다.""" + row = _fill_row() + assert row["spec"] == "210" + assert "안 정해 기본값" in row["basis"] + + +def test_고른_값은_그대로_이긴다() -> None: + row = _fill_row(fill_concrete_mpa="180") + assert row["spec"] == "180" + assert "저장 제원에서 고른 값" in row["basis"] diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py index 6a9af258..29fb04a1 100644 --- a/resources/tester/test_b06_detail_options.py +++ b/resources/tester/test_b06_detail_options.py @@ -39,7 +39,9 @@ def test_B05_는_종전대로_유무_종류_위치만(): def test_기본값_없는_필수_항목은_빈_칸으로_연다(): """첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다.""" panel = _PANEL.read_text(encoding="utf-8") - assert 'choices.unshift(["", "— 선택 —"])' in panel + # 2026-09-13 — 빈 보기를 두는 까닭이 둘이 되어(필수 + `empty_means`) 한 줄로 합쳐졌다. + # 필수 칸의 글자는 그대로 「— 선택 —」이다. + assert 'choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"])' in panel assert 'option.required === true && (option.default ?? "") === ""' in panel