diff --git a/A00_Common/design/design_structure_box.ts b/A00_Common/design/design_structure_box.ts index 639a01159..c76585021 100644 --- a/A00_Common/design/design_structure_box.ts +++ b/A00_Common/design/design_structure_box.ts @@ -5,6 +5,7 @@ * 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리): * · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`) + * · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만 * · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽) * — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝) * 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값). @@ -14,6 +15,8 @@ import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec"; export const BOX_CUSTOM = "사용자 입력"; +/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */ +export const BOX_CUSTOM_KEY = "body_custom"; /** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */ const BOX_PRESETS: Record = { "2.0×2.0": [2, 2], @@ -97,21 +100,25 @@ export function boxOptionsIn( return out; } -/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */ +/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면 + * 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */ export function bodyOf(options: Record): Instance { const width = Number(options.body_width_m); const height = Number(options.body_height_m); - if (!(width > 0 && height > 0)) return { size: "" }; const preset = Object.entries(BOX_PRESETS).find( ([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05, ); - return preset - ? { size: preset[0] } - : { size: BOX_CUSTOM, width: String(width), height: String(height) }; + if (preset) return { size: preset[0] }; + if (!(width > 0) && !(height > 0) && options[BOX_CUSTOM_KEY] !== BOX_CUSTOM) return { size: "" }; + const side = (n: number): string => (n > 0 ? String(n) : ""); + return { size: BOX_CUSTOM, width: side(width), height: side(height) }; } -/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음). */ -export function bodyOptions(body: Instance | undefined): Record { +/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다 + * 비면 표시 키만(다시 열 때 「사용자 입력」). */ +export function bodyOptions(body: Instance | undefined): Record { const preset = BOX_PRESETS[body?.size ?? ""]; - return preset ? { body_width_m: preset[0], body_height_m: preset[1] } : {}; + if (preset) return { body_width_m: preset[0], body_height_m: preset[1] }; + const blank = !(body?.width ?? "").trim() && !(body?.height ?? "").trim(); + return body?.size === BOX_CUSTOM && blank ? { [BOX_CUSTOM_KEY]: BOX_CUSTOM } : {}; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 50c414500..b2bdccdd4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -35,7 +35,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("outlet"), ], // BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값) - box_culvert: ["body_width_m", "body_height_m", "wing_in", "wing_out"], + box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"], // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는 // 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` ford_pavement: ["ford_slope_deg"], diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index bec9877ef..a94fd89b5 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -25,6 +25,8 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ installed: boolean; + /** 폼이 낸 설치 글(날개벽 · 흙막이 …) — 있으면 저장에 그 글 그대로(있음/없음으로 안 바꿈). */ + text: string; height_m: number; length_m: number; angle_deg: number; @@ -34,7 +36,8 @@ type WingPatch = Partial<{ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record { const prefix = role === "inlet" ? "wing_in" : "wing_out"; const options: Record = {}; - if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; + if (patch.installed !== undefined) + options[prefix] = patch.text ?? (patch.installed ? "있음" : "없음"); if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; @@ -82,7 +85,10 @@ function wingPatchFrom(patch: Record, prefix: string): }; const wing: WingPatch = {}; // 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정. - if (patch[prefix] !== undefined) wing.installed = wingInstalled(patch[prefix]); + if (patch[prefix] !== undefined) { + wing.installed = wingInstalled(patch[prefix]); + wing.text = String(patch[prefix]); + } const heightM = num(`${prefix}_height_m`); if (heightM !== undefined) wing.height_m = heightM; const lengthM = num(`${prefix}_length_m`); diff --git a/resources/tester/helper_52_structure_spec_box.cjs b/resources/tester/helper_52_structure_spec_box.cjs index e86007804..ac77f9982 100644 --- a/resources/tester/helper_52_structure_spec_box.cjs +++ b/resources/tester/helper_52_structure_spec_box.cjs @@ -55,7 +55,15 @@ const out = { st.body[0].height = "1.8"; }), blank: roundTrip({}), + // 52-19 — 사용자 입력에서 한 칸 · 두 칸 비우고 저장 → 다시 열기 + oneBlank: roundTrip({ body_height_m: 2.5 }), + bothBlank: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => { + st.body[0].size = "사용자 입력"; + st.body[0].width = ""; + st.body[0].height = ""; + }), keys: s.specKeys(SPEC), retired: SPEC.retired, }; +out.bothBlankAgain = roundTrip(out.bothBlank.out); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py index 95a9307a9..2091a01a1 100644 --- a/resources/tester/test_52_structure_spec_box.py +++ b/resources/tester/test_52_structure_spec_box.py @@ -76,6 +76,24 @@ def test_빈_값은_빈_채로(out): assert out["blank"]["state"]["body"][0]["size"] == "" +def test_사용자_입력_빈_칸은_다시_열어도_사용자_입력(out): + # 52-19 — 가로만 비움: 세로 손 값이 남아 사용자 입력 + 빈 가로 + assert out["oneBlank"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "2.5", + } + assert out["oneBlank"]["out"] == {"body_height_m": 2.5} + # 둘 다 비움: 표시 키만 적고 다시 열면 사용자 입력 + 빈 칸 둘 + assert out["bothBlank"]["out"] == {"body_custom": "사용자 입력"} + assert out["bothBlankAgain"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "", + } + assert out["bothBlankAgain"]["out"] == {"body_custom": "사용자 입력"} + + def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): names = _node("names") with open( diff --git a/resources/tester/ui_checks/52-19_박스_빈칸.js b/resources/tester/ui_checks/52-19_박스_빈칸.js new file mode 100644 index 000000000..4048f6acb --- /dev/null +++ b/resources/tester/ui_checks/52-19_박스_빈칸.js @@ -0,0 +1,85 @@ +/* 52-19 — BOX암거 본체 「사용자 입력」 빈 칸 저장 → 다시 열기 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「BOX암거」(52-2 edit 뒤 2.4 × 1.8 · 흙막이) + * 부르는 법: `window.__m5219 = "단계";` 를 붙여 — 단계 사이 orca reload + * one → 가로 비우고 [저장] (reload) view → 사용자 입력 · 가로 빈 칸 · 세로 1.8 + * both → 세로도 비우고 [저장] (reload) view → 사용자 입력 · 빈 칸 둘 · 파일 body_custom + * fill → 2.2 × 1.6 [저장] → 파일에 body_custom 없음 + * 기대: 유입 · 유출 구조 글(흙막이 · 없음) 그대로 · 되돌림은 52-2 restore + */ +(async () => { + const MODE = window.__m5219; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const api = await import( + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")) + .pop() + ); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, i) => + box(c)?.querySelector(`[data-spec-item="${c}.${i}"]`)?.querySelector("select, input"); + const set = async (c, i, v) => { + const el = ctl(c, i); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + await until(() => + [...document.querySelectorAll(".b05-route__irregular-list li")].some((l) => + /BOX암거$/.test(l.textContent.trim()), + ), + ); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /BOX암거$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!box("body")) (li.querySelector("button") ?? li).click(); + await until(() => !!box("body")); + await w(500); + const read = () => ({ + size: ctl("body", "size")?.value, + width: ctl("body", "width")?.value ?? null, + height: ctl("body", "height")?.value ?? null, + }); + const file = async () => + (await api.fetchDetailPipePoints(pid)).pipe_points.find((p) => p.facility === "box_culvert") + ?.options; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const wings = () => ["inlet", "outlet"].map((c) => ctl(c, "structure")?.value); + const out = { mode: MODE, reopened: read(), wings: wings() }; + if (MODE === "one") { + await set("body", "width", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "both") { + await set("body", "height", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "fill") { + await set("body", "width", "2.2"); + await set("body", "height", "1.6"); + out.saved = await save(); + out.file = await file(); + } + out.wingsAfter = wings(); + out.after = read(); + return JSON.stringify(out); +})();