fix(box): 본체 「사용자 입력」 빈 칸 저장 뒤 다시 열어도 사용자 입력 · B06 다리가 유입·유출 구조 글 유지(52-19)

- 규격 판정(bodyOf): 가로 · 세로 중 손 값 하나라도 있으면 사용자 입력 + 빈 칸은 빈 채
- 둘 다 비면 표시 키 body_custom 만 저장 · 폼 칸 목록에 넣어 숫자 채우면 지워짐
- B06 폼→조정창 다리: 흙막이 · 날개벽 글을 있음/없음으로 바꿔 적던 것 → 글 그대로
- 시험: 52-2 헬퍼 한 칸 · 두 칸 빈 경우 · ui_checks 52-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-06 01:43:24 +09:00
co-authored by Claude Opus 5.5
parent cc482d1b88
commit 1a9055444f
6 changed files with 135 additions and 11 deletions
+15 -8
View File
@@ -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<string, readonly [number, number]> = {
"2.0×2.0": [2, 2],
@@ -97,21 +100,25 @@ export function boxOptionsIn(
return out;
}
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면
* 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */
export function bodyOf(options: Record<string, string | number>): 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<string, number> {
/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다
* 비면 표시 키만(다시 열 때 「사용자 입력」). */
export function bodyOptions(body: Instance | undefined): Record<string, string | number> {
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 } : {};
}
@@ -35,7 +35,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
...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"],
@@ -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<string, number | string> {
const prefix = role === "inlet" ? "wing_in" : "wing_out";
const options: Record<string, number | string> = {};
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<string, number | string>, 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`);
@@ -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));
@@ -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(
@@ -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);
})();