From 77a769776847929786e8f1042d45676d920b5a99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:16:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(B04):=20=EC=83=88=20=EA=B4=80=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=E2=80=94=20=EC=9C=A0=EC=9E=85=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=9D=B4=EB=A9=B4=20=EC=9C=A0=EC=9E=85=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=B9=B8=20=EC=95=88=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EB=8F=99=EB=AC=BC=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=98=95=20=EC=9C=A0=20=C2=B7=20=EA=B4=80=EA=B2=BD=20=EC=88=98?= =?UTF-8?q?=20(PLAN=20623=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_apply_new_defaults` — 집수정 갈래면 `inlet_revet_*` 기본값 · 높이 안 채움 · `inlet_basin_animal` "true" · 등록부 관경 글 "1000" → 수 - 새 자동 관이 폼을 처음 거쳐도 파일이 안 바뀜(620 증상) · 옛 프로젝트 이주 없음 - 시험 — 새 `test_623_inlet_defaults`(폼 왕복 차 0 · `helper_623_inlet_defaults.cjs`) · 기대값 고침 `test_b05_new_structure_defaults` · `test_211_1`(기슭막이 관으로) · `test_b05_drainage_recommend`(관경 수) Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Router_Basins.py | 19 ++- .../tester/helper_623_inlet_defaults.cjs | 134 ++++++++++++++++++ .../tester/test_211_1_inlet_revet_height.py | 7 +- resources/tester/test_623_inlet_defaults.py | 59 ++++++++ .../tester/test_b05_drainage_recommend.py | 3 +- .../tester/test_b05_new_structure_defaults.py | 20 ++- 6 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 resources/tester/helper_623_inlet_defaults.cjs create mode 100644 resources/tester/test_623_inlet_defaults.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 24928ccb5..799ccb1bd 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -257,21 +257,26 @@ def _apply_new_defaults( if definition is None: continue options = dict(point.options or {}) + given = set(options) for field in definition.options: if field.key not in options and field.default not in (None, ""): options[field.key] = field.default - # 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`) - if options.get("inlet_type") == "집수정": + if "pipe_diameter_mm" not in given and isinstance(options.get("pipe_diameter_mm"), str): + options["pipe_diameter_mm"] = int(_float(options["pipe_diameter_mm"])) # 폼 = 수(623) + # 집수정 = 모양 L(54-39 · `DEFAULT_BASIN_SHAPE`) · 동물이동형 유 · 유입 흙막이 없음(623) + basin = options.get("inlet_type") == "집수정" + if basin: options.setdefault("inlet_structure", "L") - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`) + options.setdefault("inlet_basin_animal", "true") + for key in [k for k in options if k.startswith("inlet_revet_") and k not in given]: + del options[key] + # 보호공 · 집수정 높이 = 관경 + 0.5(139-1 · `PIPE_HEIGHT_KEYS`) · 유입 흙막이 + 0.7(211-1) keys = {field.key for field in definition.options} diameter_m = _float(options.get("pipe_diameter_mm")) / 1000 if diameter_m > 0: for key in PIPE_HEIGHT_KEYS: - if key in keys: - side = ( - "inlet" if key == "inlet_revet_height_m" else "" - ) # 유입 흙막이 + 0.7(211-1) + if key in keys and not (basin and key.startswith("inlet_revet_")): + side = "inlet" if key == "inlet_revet_height_m" else "" options.setdefault(key, pipe_wall_min_height_m(diameter_m, side)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) diff --git a/resources/tester/helper_623_inlet_defaults.cjs b/resources/tester/helper_623_inlet_defaults.cjs new file mode 100644 index 000000000..df063c226 --- /dev/null +++ b/resources/tester/helper_623_inlet_defaults.cjs @@ -0,0 +1,134 @@ +/* 623 헬퍼 — 서버가 새로 만든 관 옵션(`_apply_new_defaults`)을 공용 명세 폼 `write → read` 에 돌리고 + * 같은 종류 병합(`mergeFacilityOptions`)까지 — 폼을 처음 거칠 때 파일이 바뀌는 칸(지워짐 · 더함 · 바뀜). + * 입력 = 표준입력 JSON(옵션 목록) · 출력 = 표준출력 JSON 목록 · 짝 = test_623_inlet_defaults.py. + * 가짜 DOM 틀 = helper_173_7_inlet_animal.cjs. */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + this._on = {}; + this._text = ""; + this.value = ""; + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._classes.add(c)), + remove: (...n) => n.forEach((c) => self._classes.delete(c)), + toggle: (c, f) => + (f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c), + contains: (c) => self._classes.has(c), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent ?? "").join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k)); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + add(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener(type, fn) { + (this._on[type] ??= []).push(fn); + } + fire(type) { + (this._on[type] ?? []).forEach((fn) => fn({ target: this })); + } + closest() { + return null; + } + matches(sel) { + return sel.split(",").some((s) => { + s = s.trim(); + return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase(); + }); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + (n.children ?? []).forEach((k) => { + if (k.matches?.(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { createElement: (t) => new FakeNode(t) }; +globalThis.queueMicrotask = (fn) => fn(); +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +const STUBS = { + "@ui/ui_template_elements": { showToast: () => {} }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null }, +}; + +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }, +}); + +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs( + path.join(ROOT, "A00_Common", "design", "logic", "design_structure_pipe.ts"), +); +const { mergeFacilityOptions } = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Drainage_Facility_Merge.ts"), +); + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const inputs = JSON.parse(fs.readFileSync(0, "utf-8")); +const out = inputs.map((options) => { + const form = createSpecForm(null, {}); + form.write(PIPE_SPEC, options); + const merged = + mergeFacilityOptions({ facility: "pipe", options }, { facility: "pipe", options: form.read() }) + .options ?? {}; + return { + removed: Object.keys(options).filter((k) => !(k in merged)), + added: Object.keys(merged).filter((k) => !(k in options)), + changed: Object.keys(options).filter((k) => k in merged && !same(options[k], merged[k])), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_211_1_inlet_revet_height.py b/resources/tester/test_211_1_inlet_revet_height.py index f7813439f..572b9008e 100644 --- a/resources/tester/test_211_1_inlet_revet_height.py +++ b/resources/tester/test_211_1_inlet_revet_height.py @@ -47,7 +47,12 @@ def test_파이썬_기본_높이_유입만_0_7(): def test_새_관_기본값_유입_흙막이만_0_7(): - fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200}) + # 유입 기슭막이 관만 유입 흙막이 칸을 받음 — 기본 집수정이면 안 채움(623) + fresh = PipePoint( + chainage_m=10.0, + source="stream", + options={"pipe_diameter_mm": 1200, "inlet_type": "기슭막이"}, + ) basins._apply_new_defaults([fresh]) assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9) assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7) diff --git a/resources/tester/test_623_inlet_defaults.py b/resources/tester/test_623_inlet_defaults.py new file mode 100644 index 000000000..14b3d4b7b --- /dev/null +++ b/resources/tester/test_623_inlet_defaults.py @@ -0,0 +1,59 @@ +"""623 새 관 기본값 — 유입 집수정이면 유입 흙막이 칸 안 채움 · 동물이동형 유(요구 530 E1 · 분석 620). + +못박는 것 + - 서버가 새로 만든 관(`_apply_new_defaults`) = 집수정 갈래 · `inlet_revet_*` 없음 · `inlet_basin_animal` "true". + - 그 옵션을 공용 명세 폼 `write → read` + 병합(`mergeFacilityOptions`)에 돌리면 차이 0 + (지워짐 0 · 더함 0 · 바뀜 0) — 폼을 처음 거쳐도 파일이 안 바뀜(`helper_623_inlet_defaults.cjs`). + - 유입 기슭막이로 받은 관 · 이미 적힌 유입 흙막이 칸은 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_pipes import PipePoint + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +def _fresh(**options) -> dict: + pipe = PipePoint(chainage_m=10.0, source="stream", options=options or None) + basins._apply_new_defaults([pipe]) + return pipe.options + + +def test_집수정이면_유입_흙막이_없음_동물이동형_유(): + for options in (_fresh(), _fresh(pipe_diameter_mm=800)): + assert options["inlet_type"] == "집수정" + assert not [k for k in options if k.startswith("inlet_revet_")] + assert options["inlet_basin_animal"] == "true" and options["inlet_structure"] == "L" + assert _fresh()["pipe_diameter_mm"] == 1000 # 등록부 글 "1000" → 수(폼과 같음) + + +def test_기슭막이_관과_적힌_칸은_그대로(): + soil = _fresh(inlet_type="기슭막이") + assert soil["inlet_revet_form"] == "돌쌓기(찰)" and soil["inlet_revet_height_m"] == 1.7 + assert "inlet_basin_animal" not in soil + kept = _fresh(inlet_revet_form="돌쌓기(메)", inlet_basin_animal="false") + assert kept["inlet_revet_form"] == "돌쌓기(메)" and kept["inlet_basin_animal"] == "false" + + +def test_서버가_만든_관_폼_왕복_차이_0(): + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + inputs = [_fresh(), _fresh(pipe_diameter_mm=800), _fresh(pipe_diameter_mm=1200)] + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_623_inlet_defaults.cjs")], + input=json.dumps(inputs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + assert json.loads(done.stdout) == [{"removed": [], "added": [], "changed": []}] * 3 diff --git a/resources/tester/test_b05_drainage_recommend.py b/resources/tester/test_b05_drainage_recommend.py index f145898a0..7ab3a7bac 100644 --- a/resources/tester/test_b05_drainage_recommend.py +++ b/resources/tester/test_b05_drainage_recommend.py @@ -99,7 +99,8 @@ def test_new_pipe_gets_master_default_not_recommendation(monkeypatch, facility, """새로 놓인 관 = 배관 · 마스터 기본 관경 — 추천(관경 · BOX암거 · 세월교)은 안 얹는다.""" carried = _run_build(monkeypatch, _basin(100.0, facility, diameter), None) assert carried[0].facility == PIPE_FACILITY_PIPE - assert carried[0].options["pipe_diameter_mm"] == _master_pipe_diameter() + # 등록부 글 "1000" → 수로 적힘(623 · 폼 왕복과 같음) + assert carried[0].options["pipe_diameter_mm"] == int(_master_pipe_diameter()) def test_existing_auto_pipe_is_untouched(monkeypatch): diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index b6177eddf..d471a9987 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -30,13 +30,19 @@ def _defaults(type_id: str) -> dict: } +def _basin_defaults() -> dict: + """새 관(유입 집수정) — 유입 흙막이 칸 없음 · 모양 L · 동물이동형 유 · 관경 수(623).""" + kept = {k: v for k, v in _defaults("pipe").items() if not k.startswith("inlet_revet_")} + return {**kept, "pipe_diameter_mm": 1000, "inlet_structure": "L", "inlet_basin_animal": "true"} + + def test_새_관만_기본값_빈_키에만(): fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800}) basins._apply_new_defaults([fresh]) # 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39) - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5} - expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights} + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 집수정이면 유입 흙막이 없음(623) + heights = {"outlet_revet_height_m": 1.3, "inlet_basin_height_m": 1.3} + expected = {**_basin_defaults(), "pipe_diameter_mm": 800, **heights} assert fresh.options == expected assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") @@ -65,10 +71,10 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch): _, points = basins._build("p", Ctx(), [old]) by = {p.chainage_m: p for p in points} assert by[100.0].options is None # 옛 빈칸 그대로 - # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) - # 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7} - assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights} + # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) · 동물이동형 유 · 유입 흙막이 없음(623) + # 기본 관경 1000 → 1.5(139-1) + heights = {"outlet_revet_height_m": 1.5, "inlet_basin_height_m": 1.5} + assert by[300.0].options == {**_basin_defaults(), **heights} def test_월류_폭_기본값은_등록부_엔진과_같음():