From 0eebb50c03d6558deea50a0fc59fab5b23e9b2cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:40:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(b05,b08):=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EB=B6=80=20=EC=B9=B8=20=EC=B1=84=EC=9B=80=20A2=C2=B7A3=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=9C=EA=B1=B0=20=EC=97=B0=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=EB=91=90?= =?UTF-8?q?=EA=BB=98=C2=B7=EB=85=B8=ED=8F=AD=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표가 읽는데 등록부에 없어 영영 안 서던 칸 — 옛 저장본은 안 깨지고 비면 사유(어디서 적는지) - 개거(점 배치) length_m · 물넘이포장 thickness_cm·length_m(detail · 구조물 집계표에서도 고침) - B05 시설 칸에 물넘이 두께·길이 입력 — 시설 저장이 칸을 통째로 갈아 끼워 집계표로 적은 값이 지워지던 길을 막음 - 700줄 한계로 유입구 형식 목록을 _Fields 로 옮김(부르는 쪽 그대로) - 사유 문구: 「open_ditch 연장」 키 노출 · 「연장·면적이 0」 → 무엇을 적으면 서는지 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 29 +++++++++ .../B05_Profile_UI_Drainage_Facility.ts | 42 ++++++------- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 26 ++++++++ .../B08_Quantity_Engine_ObservedUnit.py | 7 ++- ..._Quantity_Engine_UnitQuantity_Revetment.py | 4 +- B08_Quantity/B08_Quantity_Wording.py | 3 + .../tester/test_b05_registry_fill_a2_a3.py | 60 +++++++++++++++++++ 7 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_b05_registry_fill_a2_a3.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 820ef552..6362f9c3 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -332,6 +332,26 @@ "unit": "%", "default": null, "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" } ] }, @@ -479,6 +499,15 @@ "required": false, "phase": "detail", "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" + }, + { + "key": "length_m", + "label": "연장", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" } ] }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1caa2b97..409ed2f1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -45,7 +45,10 @@ import { fordSection, grid, group, + INLET_KINDS, INLET_REVET_KEYS, + inletKindOf, + type InletStructureKind, labeled, numberInput, optionalSelect, @@ -57,29 +60,8 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; -/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). - * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ -export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; - -/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 - * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). - * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 - * 유입측 형식(`inlet_structure`). */ -const INLET_KINDS: ReadonlyArray<{ - label: string; - type: "기슭막이" | "집수정"; - structure: InletStructureKind; -}> = [ - { label: "기슭막이", type: "기슭막이", structure: "revet" }, - { label: "집수정", type: "집수정", structure: "auto" }, - { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, - { label: "집수정 I형", type: "집수정", structure: "I" }, - { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, - { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, -]; - -const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => - INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; +// 유입구 형식 목록은 700줄 한계로 `_Fields` 로 옮김(2026-09-14) — 부르던 곳은 그대로 여기서. +export type { InletStructureKind }; /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ @@ -382,7 +364,15 @@ export function createFacilityOptionsForm( // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. const fordSlope = numberInput("0.1"); fordSlope.placeholder = "노면 기울기"; - const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1))); + // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3). + // 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다. + const fordThickness = numberInput("1"); + const fordLength = numberInput("0.1"); + const fordSlopeRow = grid( + labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)), + labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)), + labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)), + ); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ @@ -634,6 +624,8 @@ export function createFacilityOptionsForm( // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. fordAutoDepthM = null; fordSlope.value = text("ford_slope_pct"); + fordThickness.value = text("thickness_cm"); + fordLength.value = text("length_m"); revetSide.value = text("side") || "양쪽"; const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -679,6 +671,8 @@ export function createFacilityOptionsForm( putNumber(options, "ford_width_m", fordWidth.value); putFordHeight(options); putNumber(options, "ford_slope_pct", fordSlope.value); + putNumber(options, "thickness_cm", fordThickness.value); + putNumber(options, "length_m", fordLength.value); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 0bead6cd..1b3eb9ce 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -10,6 +10,32 @@ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend"; +/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */ + +/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). + * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ +export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; + +/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 + * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). + * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 + * 유입측 형식(`inlet_structure`). */ +export const INLET_KINDS: ReadonlyArray<{ + label: string; + type: "기슭막이" | "집수정"; + structure: InletStructureKind; +}> = [ + { label: "기슭막이", type: "기슭막이", structure: "revet" }, + { label: "집수정", type: "집수정", structure: "auto" }, + { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, + { label: "집수정 I형", type: "집수정", structure: "I" }, + { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, + { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, +]; + +export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => + INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; + /* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */ /** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */ diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index 878de5d4..b8ba88ed 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -273,7 +273,12 @@ def expand_observed( if scale <= 0: from B08_Quantity.B08_Quantity_Wording import type_label - return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"] + where = ( + "면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬" + if found.get("unit") == "㎡" + else "연장이 0 — 연장을 적으면 섬" + ) + return [], [f"{type_label(type_id)}의 {where}"] source_key = str(found.get("source") or "") options = structure.get("options") or {} diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 939c4e05..f138f672 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -519,7 +519,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION if length_m <= 0: - return [], ["연장이 없어 전개하지 않음"] + from B08_Quantity.B08_Quantity_Wording import option_missing + + return [], [option_missing("length_m", "open_ditch")] spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip() table = OPEN_DITCH_FORMS.get(spec) if table is None: diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py index 86f22eca..673ebde7 100644 --- a/B08_Quantity/B08_Quantity_Wording.py +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -28,6 +28,7 @@ TYPE_LABELS = { "pipe": "배수관", "pipe_inlet_basin": "배수관 유입부 집수정", "ford_pavement": "물넘이포장", + "open_ditch": "개거(겉도랑)", "ford_bridge": "세월교", "box_culvert": "BOX암거", # 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님. @@ -44,6 +45,8 @@ OPTION_LABELS = { "form": ("옹벽 형식", "구조물 상세 입력"), "height_m": ("높이(m)", "구조물 배치"), "length_m": ("연장(m)", "구조물 배치"), + # 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3). + "thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"), # 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침. # ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨). "face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"), diff --git a/resources/tester/test_b05_registry_fill_a2_a3.py b/resources/tester/test_b05_registry_fill_a2_a3.py new file mode 100644 index 00000000..4bbd8da1 --- /dev/null +++ b/resources/tester/test_b05_registry_fill_a2_a3.py @@ -0,0 +1,60 @@ +"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리. + +A2 개거(점 배치) — 연장 칸 · A3 물넘이포장(관 지점 시설) — 두께·노폭 방향 길이 칸. +⚠ 옛 저장본(칸 없음)은 안 깨지고 **사유가 보임** — 「값이 없으면 표가 안 선다」가 아니라 「사유를 보인다」. +""" + +from __future__ import annotations + +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 B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _ditch(**options) -> dict: + return build_table( + [{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}] + )["structures"][0] + + +def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None: + keys = {option.key for option in structure_type_map()["open_ditch"].options} + assert "length_m" in keys + item = StructureInstance( + type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12} + ) + _validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨 + + +def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None: + assert _ditch(length_m=12)["components"] + old = _ditch() # 옛 저장본 — 칸이 없음 + assert not old["components"] + assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다") + + +def _ford(**options) -> dict: + point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options} + return build_table(facility_structures([point]))["structures"][0] + + +def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None: + keys = {option.key: option for option in structure_type_map()["ford_pavement"].options} + assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail" + row = _ford(ford_width_m=5, thickness_cm=20, length_m=8) + assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("㎡", 40.0) + + +def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None: + old = _ford(ford_width_m=5) + assert not old["components"] and "포장 두께(㎝)" in old["notes"][0] + no_length = _ford(ford_width_m=5, thickness_cm=20) + assert not no_length["components"] and "포장 길이" in no_length["notes"][0] From a1b1182b9ba74005b4334ff4f9c83a7763d66ab7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:49:38 +0900 Subject: [PATCH 2/2] =?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}"