From a1b1182b9ba74005b4334ff4f9c83a7763d66ab7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:49:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(b05):=20=EA=B3=84=EA=B3=A1=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=EC=84=A4=20[=EC=A0=80=EC=9E=A5]=EC=9D=B4?= =?UTF-8?q?=20=ED=8F=BC=EC=9D=B4=20=EB=AA=A8=EB=A5=B4=EB=8A=94=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B0=EB=8D=98=20=EA=B2=83=20=E2=80=94?= =?UTF-8?q?=20=ED=8F=BC=20=EC=B9=B8=EB=A7=8C=20=EA=B0=88=EC=95=84=20?= =?UTF-8?q?=EB=81=BC=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장이 옵션을 통째로 갈아 끼워 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 조용히 지워졌음 - 같은 시설 종류면 폼이 적는 칸만 갈고 나머지는 이어 붙임 · 종류를 바꿀 때만 통째로 - 폼이 아는 칸을 폼이 비우면 지움(사용자가 폼에서 한 일) - 폼 칸 목록 거울 시험 — 폼에 칸을 늘리고 목록에 안 넣으면 시험이 잡음 - ORCA 936be972: 집계표로 기초유 적고 B05 폼 고침 → 재계산 요청에 기초유 남음 확인 · 값·캐시 되돌림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 73 +++++++++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 5 +- .../tester/test_b05_facility_options_merge.py | 118 ++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts create mode 100644 resources/tester/test_b05_facility_options_merge.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts new file mode 100644 index 00000000..712b5f88 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility_Merge.ts + * 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음** + * (2026-09-14 브레인 판정). + * + * ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이 + * 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을 + * 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지 + * 지우던 자리. + * ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음. + * ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐) + * 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음. + * ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는** + * 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`. + * ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +const revetKeys = (side: "inlet" | "outlet"): string[] => + ["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`); +const wingKeys = (side: "in" | "out"): string[] => [ + `wing_${side}`, + `wing_${side}_height_m`, + `wing_${side}_length_m`, + `wing_${side}_angle_deg`, +]; + +/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */ +export const FORM_OPTION_KEYS: Readonly> = { + pipe: [ + "pipe_diameter_mm", + "pipe_kind", + "wing_wall_type", + "inlet_type", + "inlet_structure", + "inlet_basin_form", + "inlet_basin_material", + "inlet_basin_length_m", + ...revetKeys("inlet"), + "outlet_type", + ...revetKeys("outlet"), + ], + box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], + ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + ford_bridge: [ + "pipe_kind", + "pipe_diameter_mm", + "pipe_count", + "ford_width_m", + "ford_height_m", + ...wingKeys("in"), + ...wingKeys("out"), + ], + revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], +}; + +interface MergeableAttributes { + facility: string; + start_m?: number; + end_m?: number; + options?: Record; +} + +/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */ +export function mergeFacilityOptions( + previous: T | null, + next: T, +): T { + if (!previous || previous.facility !== next.facility) return next; + const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []); + const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key)); + const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) }; + return { ...next, options: Object.keys(options).length ? options : undefined }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index e8c1f183..045be36a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; @@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { + // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. + const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); - facilityStore.set(toChainageM, attributes); + facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes)); if (Math.abs(fromChainageM - toChainageM) > 0.005) { // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, // attach가 새 위치로 시설 정보를 승계한다. diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py new file mode 100644 index 00000000..9b4a4593 --- /dev/null +++ b/resources/tester/test_b05_facility_options_merge.py @@ -0,0 +1,118 @@ +"""B05 시설 [저장]이 폼이 모르는 칸을 안 지우는가 (2026-09-14 브레인 판정). + +⚠ 앞서 저장이 옵션을 통째로 갈아 끼워, 구조물 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 + B05 에서 그 시설을 한 번 더 저장하면 조용히 지워졌음 — 되돌릴 길이 없는 사고. +짝: `B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts` — 실제 TS 를 옮겨 돌림. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MERGE_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts" +FORM_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts" + +RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { FORM_OPTION_KEYS, mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([previous, next]) => mergeFacilityOptions(previous, next)); +writeFileSync(process.argv[3], JSON.stringify({ out, keys: FORM_OPTION_KEYS })); +""" + +PIPE_BEFORE = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 1000, + "inlet_type": "집수정", + "inlet_basin_form": "□형(기본형)", + "revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음 + "inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음 + }, +} +# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄 +PIPE_FORM = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 800, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + }, +} +REVET_BEFORE = {"facility": "revetment", "options": {"side": "양쪽", "back_len_cm": "35"}} +TYPE_CHANGE = {"facility": "ford_pavement", "options": {"ford_width_m": 5}} + + +@pytest.fixture(scope="module") +def merged(tmp_path_factory) -> dict: + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + out = tmp_path_factory.mktemp("merge_js") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(MERGE_TS), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(RUNNER, encoding="utf-8") + cases = [ + [PIPE_BEFORE, PIPE_FORM], + [REVET_BEFORE, {"facility": "revetment", "options": {"side": "좌"}}], + [PIPE_BEFORE, TYPE_CHANGE], + [None, PIPE_FORM], + ] + (out / "cases.json").write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(out / "cases.json"), str(out / "result.json")], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads((out / "result.json").read_text(encoding="utf-8")) + + +def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None: + options = merged["out"][0]["options"] + assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음 + assert options["inlet_basin_before_m"] == 1.5 + assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김 + assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움 + assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"} + + +def test_시설_종류를_바꾸면_통째로_갈아_끼움(merged) -> None: + assert merged["out"][2] == TYPE_CHANGE + assert merged["out"][3] == PIPE_FORM + + +def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None: + """폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘.""" + source = FORM_TS.read_text(encoding="utf-8") + body = source[source.index("readOptions() {") :] + branches = re.split(r'current === "(\w+)"', body)[1:] + assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"] + for facility, block in zip(branches[::2], branches[1::2]): + written = set(re.findall(r'putNumber\(options, "(\w+)"', block)) + written |= set(re.findall(r"options\.(\w+) =", block)) + missing = written - set(merged["keys"][facility]) + assert not missing, f"{facility}: 폼이 적는데 목록에 없음 {missing}"