From e92dcb4247a7141c4f72a6d9350716390a54438a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:43:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05):=20=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=E3=80=8C=EB=8F=99=EB=AC=BC=EC=9D=B4=EB=8F=99=ED=98=95=E3=80=8D?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC=20=EC=B9=B8=20=E2=80=94=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20bool=20=EC=B9=B8=20=EA=BC=B4=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=82=A4=20inlet=5Fbasin=5Fanimal=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EA=B0=92=20=3D=20=EC=B0=B8(173-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_structure_form.ts | 11 +- A00_Common/design/design_structure_pipe.ts | 8 + A00_Common/design/design_structure_spec.ts | 3 +- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 1 + resources/master_template/구조물정의.json | 1 + .../tester/helper_173_7_inlet_animal.cjs | 152 ++++++++++++++++++ resources/tester/helper_54_10_soil_tiers.cjs | 1 + resources/tester/test_173_7_inlet_animal.py | 47 ++++++ 8 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 resources/tester/helper_173_7_inlet_animal.cjs create mode 100644 resources/tester/test_173_7_inlet_animal.py diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 9a7ee8c20..de75abeb6 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -4,7 +4,8 @@ * * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] · * `max` 에 차면 [추가] 숨김 - * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) · + * 체크(공용 체크박스 · "true" / "false") * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 @@ -40,6 +41,7 @@ import { import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; import { followPipeHeights } from "../../common_util/common_util_culvert_sets"; +import { createCheckbox } from "../../ui_template/ui_template_checkbox"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { @@ -142,6 +144,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = tag ? "" : now; return select; } + if (item.kind === "bool") { + // 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false" + const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" }); + box.input.value = String(box.input.checked); + box.input.addEventListener("change", () => (box.input.value = String(box.input.checked))); + return box.root; + } if (item.kind === "text") { const input = document.createElement("input"); input.type = "text"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 3c81eca63..3aa987a31 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -70,6 +70,14 @@ export const PIPE_SPEC: StructureSpec = { [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), + // 집수정만 · 빈 값 = 참(키 없는 옛 관도 동물이동형 · 173-7) · 저장만(B07 · B08 안 읽음) + { + id: "animal", + label: "동물이동형", + kind: "bool", + default: "true", + by: { item: "structure", map: { [BASIN]: { key: "inlet_basin_animal" } } }, + }, ], }, { diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 3ba11d1d1..5c80408e8 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -32,7 +32,8 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - kind: "select" | "number" | "text" | "auto"; + /** bool = 체크박스 · 저장 글 "true" / "false" · 빈 값 = `default`(173-7). */ + kind: "select" | "number" | "text" | "auto" | "bool"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 49dc35c2e..70ea88540 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -28,6 +28,7 @@ export const FORM_OPTION_KEYS: Readonly> = { "inlet_structure", // 집수정 길이 · 전 · 후는 폼에서 빠짐(54-1) — 옛 값 그대로 둠 "inlet_basin_height_m", + "inlet_basin_animal", // 집수정 동물이동형(173-7) ...revetKeys("inlet"), "outlet_type", ...revetKeys("outlet"), diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index ecfb17498..6cc24ba67 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3332,6 +3332,7 @@ "inlet.height": "높이 (m)", "inlet.before": "기준측점 전 (m)", "inlet.after": "기준측점 후 (m)", + "inlet.animal": "동물이동형", "outlet.structure": "구조", "outlet.structure=기슭막이": "흙막이", "outlet.form": "형태", diff --git a/resources/tester/helper_173_7_inlet_animal.cjs b/resources/tester/helper_173_7_inlet_animal.cjs new file mode 100644 index 000000000..609c9c8db --- /dev/null +++ b/resources/tester/helper_173_7_inlet_animal.cjs @@ -0,0 +1,152 @@ +/* 집수정 「동물이동형」 체크 칸(PLAN 173-7) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. + * 빈 값 = 체크 · 끄면 "false" · 다시 켜면 "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. + * 짝 = `test_173_7_inlet_animal.py` · 화면 그릇 흉내는 `helper_54_10_soil_tiers.cjs` 꼴(+ 듣개). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +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 }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + 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`)); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); + +const form = createSpecForm(null, {}); +const box = () => + form.root + .querySelectorAll("label") + .find((l) => l.dataset.specItem === "inlet.animal") + ?.querySelector("input") ?? null; +const animal = () => form.read().inlet_basin_animal ?? null; +const toggle = (on) => { + const input = box(); + input.checked = on; + input.fire("change"); +}; + +const out = {}; +form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정" }); +out.blank = { checked: box()?.checked ?? null, saved: animal() }; +toggle(false); +out.off = { checked: box().checked, saved: animal() }; +toggle(true); +out.on = { checked: box().checked, saved: animal() }; +form.write(PIPE_SPEC, { + pipe_diameter_mm: 1000, + inlet_type: "집수정", + inlet_basin_animal: "false", +}); +out.storedOff = { checked: box().checked, saved: animal() }; +form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "기슭막이" }); +out.soil = { shown: box() !== null, saved: animal() }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_54_10_soil_tiers.cjs b/resources/tester/helper_54_10_soil_tiers.cjs index 1ee678bda..aea1daad9 100644 --- a/resources/tester/helper_54_10_soil_tiers.cjs +++ b/resources/tester/helper_54_10_soil_tiers.cjs @@ -88,6 +88,7 @@ const toasts = []; const STUBS = { "@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) }, "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, "./B06_Section_UI_Page_Ford_Controls": { applyFordFormOptions: () => {}, applyBoxFormOptions: () => {}, diff --git a/resources/tester/test_173_7_inlet_animal.py b/resources/tester/test_173_7_inlet_animal.py new file mode 100644 index 000000000..b8539c476 --- /dev/null +++ b/resources/tester/test_173_7_inlet_animal.py @@ -0,0 +1,47 @@ +"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참. + +공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`) ++ 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_7_inlet_animal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_빈_값은_체크_저장_안_함(out: dict) -> None: + assert out["blank"] == {"checked": True, "saved": None} + + +def test_끄면_false_다시_켜면_true(out: dict) -> None: + assert out["off"] == {"checked": False, "saved": "false"} + assert out["on"] == {"checked": True, "saved": "true"} + assert out["storedOff"] == {"checked": False, "saved": "false"} + + +def test_흙막이엔_칸_없음(out: dict) -> None: + assert out["soil"] == {"shown": False, "saved": None} + + +def test_배관_폼_키_목록에_새_키() -> None: + merge = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts").read_text("utf-8") + assert '"inlet_basin_animal"' in merge