From bfe4eeb5491f3bb9f1c605757e3af09600fd8d22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:08:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(structure):=20B06=20=EB=8B=A4=EB=8B=A8=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=E2=86=94=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=9E=87=EA=B8=B0(54-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단도 다단 벽 누르기 = 옛 「추가 기슭막이 (n단)」 칸 대신 그 단의 흙막이 컨테이너를 켜고 보임 - 흙막이 n 컨테이너 형태 · 높이 → 다단 n 단 조정값 · 길이 · 전 · 후 → 단별 구간값(높이 한도 · 지형 맞춤 규칙 그대로 · 한도 넘은 요청은 폼에 되돌림) - 그린 다단 형태 · 높이 → 컨테이너 빈 칸 채움(유입 · 유출 벽과 같은 규칙) - 컨테이너 수 = 지형이 세운 단 수 · 넘친 컨테이너 · 단 수 0 의 흙막이 값은 가리기만 하고 파일에 남김 - 시험 test_54_10_soil_tiers · 화면 점검 ui_checks/54-10_다단_흙막이 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_structure_form.ts | 16 +- .../design/design_structure_pipe_calc.ts | 16 +- A00_Common/design/design_view_cross.ts | 12 +- B06_Section/B06_Section_UI_Adjust_Dock.ts | 21 ++ .../B06_Section_UI_Cross_Structure_Panel.ts | 1 + .../B06_Section_UI_Page_Pipe_Options.ts | 98 +++++++- .../B06_Section_UI_Page_Structures_Panel.ts | 4 +- .../B06_Section_UI_Page_Structures_Spec.ts | 15 +- resources/tester/helper_54_10_soil_tiers.cjs | 216 ++++++++++++++++++ resources/tester/test_54_10_soil_tiers.py | 92 ++++++++ .../tester/ui_checks/54-10_다단_흙막이.js | 107 +++++++++ 11 files changed, 580 insertions(+), 18 deletions(-) create mode 100644 resources/tester/helper_54_10_soil_tiers.cjs create mode 100644 resources/tester/test_54_10_soil_tiers.py create mode 100644 resources/tester/ui_checks/54-10_다단_흙막이.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index dd0a7f38c..4adf4a6c4 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -56,7 +56,8 @@ export interface SpecFormHooks { hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; onChange?: (c: SpecContainer, item: SpecItem) => void; /** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 · - * [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */ + * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · + * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; } @@ -228,15 +229,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return box.root; }; - /** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */ + /** 밖에서 정한 개수 — 모자라면 빈 인스턴스를 늘림(늘었으면 true) · 넘친 인스턴스 · 잇기는 + * 가리기만 하고 값에 남김(54-10 · 파일에 그대로). */ const fit = (c: SpecContainer, list: Instance[]): boolean => { const n = hooks.count?.(c); - if (c.required || n === undefined || n === null || n === list.length) return false; - for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null); - list.splice(n); + if (c.required || n === undefined || n === null || n <= list.length) return false; while (list.length < n) list.push({}); return true; }; + const shown = (c: SpecContainer, list: Instance[]): Instance[] => { + const n = c.required ? null : hooks.count?.(c); + return n === undefined || n === null ? list : list.slice(0, n); + }; function paint(quiet = true): void { rows.length = 0; @@ -245,7 +249,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); if (fit(c, list)) fitted.push(c); - list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); + shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; const add = document.createElement("button"); add.type = "button"; diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index cf83b8aaa..6e60c27bf 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -5,7 +5,7 @@ * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). - * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만. + * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만. * ========================================================================== */ import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; @@ -51,11 +51,17 @@ export function pipeLengthM(chainageM: number | null): number | null { return typeof length === "number" && length > 0 ? length : null; } -/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */ +/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 중 지형이 세운 단(54-10) — 횡단 자료가 없으면 + * null(모름). 저장 단 수가 더 커도 넘친 단은 컨테이너를 안 세움(값은 파일에 그대로). */ export function pipeExtraCount(chainageM: number | null): number | null { - const owner = ownerSection(chainageM)?.owner; - if (!owner) return null; - return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0; + const found = ownerSection(chainageM); + if (!found) return null; + const { owner, sections } = found; + const outlet = extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0; + if (!outlet || !owner.design) return outlet; + const counts = { basin: 0, ...owner.design.extra_wall_counts, outlet }; + const probe = { ...owner, design: { ...owner.design, extra_wall_counts: counts } }; + return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet; } /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index e98171105..089f92287 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -41,7 +41,7 @@ import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; -import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; +import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, replayDraftPipeOptions, @@ -73,7 +73,11 @@ import { createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; -import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { + pipeExtraCount, + setPipeExtraCountSource, + setPipeSectionsSource, +} from "./design_structure_pipe_calc"; import { bindDesignView, bindPipeOptions, @@ -152,6 +156,7 @@ export async function mountCrossView( // 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음 // (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). if (adjust?.h == null) return null; + if (role !== "inlet" && role !== "outlet") return adjust.h; // 다단 = 기하가 되받은 적용 높이(54-10) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, @@ -164,6 +169,7 @@ export async function mountCrossView( const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④) + if (role !== "inlet" && role !== "outlet") return revetFormOf(adjust.m); // 다단(54-10) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. @@ -332,6 +338,8 @@ export async function mountCrossView( ford: stationControls.ford, box: stationControls.box, revetOffset: stationControls.revetOffset, + structureSpan: stationControls.structureSpan, + soilTiers: pipeExtraCount, overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), }; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 83e8ec038..c03d8bade 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -145,6 +145,27 @@ function relocateSideRows(panelRoot: HTMLElement): void { const group = target.closest("fieldset"); if (!group) return; const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet"); + // 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 · + // 단 수 · 옵션은 부모(유출) 칸에 그대로 + const soil = + ownerSlot === sideSlots.outlet && slotUsable(ownerSlot) + ? ownerSlot + .closest(".b05-structure__spec") + ?.querySelectorAll('fieldset[data-spec-container="soil_add"]')[ + Number(panelRoot.dataset.extraIndex) + ] + : undefined; + if (soil) { + moveRows( + panelRoot, + SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""), + ownerSlot, + ); + setGroupLabel(soil, ""); + soil.classList.add("is-active"); + soil.scrollIntoView({ block: "nearest" }); + return; + } const owner = ownerSlot.closest("fieldset"); if (owner && owner !== group) owner.after(group); group.hidden = false; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index c9becd983..3eb0fe8a4 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -588,6 +588,7 @@ export function buildStructurePanel( // 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다. // 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)". root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? ""); + root.dataset.extraIndex = isExtra ? String(extraIndex) : ""; // 흙막이 컨테이너 차례(54-10) const tiers = tierChannel === "outlet" ? deps.extraState() diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 1d3c8d98f..063244321 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -12,7 +12,15 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; -import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; +import { + applySpanPatch, + EXTRA_WALL_MIN_HEIGHT_M, + REVET_EMBED_DEPTH_M, + revetFormOf, + revetHeightLimit, + revetWallSpec, +} from "./B06_Section_UI_Cross_Culvert_Const"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; type StationControls = ReturnType; @@ -23,6 +31,10 @@ export interface PipeOptionsContext { ford: StationControls["ford"]; box: StationControls["box"]; revetOffset: StationControls["revetOffset"]; + /** 다단 단별 구간값 — 흙막이 컨테이너 길이 · 전 · 후(54-10). */ + structureSpan?: StationControls["structureSpan"]; + /** 흙막이 컨테이너 수 = 지형이 세운 유출 다단 수(`pipeExtraCount` · 54-10) — 없으면 다단 안 만짐. */ + soilTiers?: (chainageM: number) => number | null; /** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */ overrideOptions: (chainageM: number, values: Record) => void; refreshCard: (chainageM: number) => void; @@ -129,6 +141,7 @@ export function applyPipeOptionsToCache( ); } } + if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole); own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m")); @@ -149,6 +162,89 @@ export function applyPipeOptionsToCache( } } +/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에 + * 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */ +const soilSent = new Map(); + +/** + * 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값 + * (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 — + * 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) · + * 형태 한계 · 하한을 넘은 요청만 폼에 되돌림. + */ +function applySoilTiers( + ctx: PipeOptionsContext, + owner: CrossSection, + patch: Record, + whole: boolean, +): void { + const culvert = owner.culvert!; + const ownerForm = revetWallSpec( + culvert.outlet, + ctx.revetOffset.adjustFor(owner, "outlet"), + false, + culvert.diameter_m, + ).form; + const value = (n: number, item: string): number | undefined => { + const raw = patch[`soil_add_${n}_${item}`]; + return raw === undefined || raw === "" || !Number.isFinite(Number(raw)) + ? undefined + : Number(raw); + }; + const blank = whole ? null : undefined; + const clipped: Record = {}; + for (let n = 1; n <= (ctx.soilTiers?.(owner.chainage_m) ?? 0); n++) { + const role = `extra${n - 1}` as const; + const key = `${owner.chainage_m.toFixed(2)}:${role}`; + const form = patch[`soil_add_${n}_form`]; + const want = { + h: value(n, "height") ?? blank, + m: typeof form === "string" && form ? form : blank, + }; + if (want.h != null) { + const limit = revetHeightLimit( + revetFormOf(want.m ?? ctx.revetOffset.adjustFor(owner, role).m, ownerForm), + ); + const h = Math.min( + Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1), + limit, + ); + if (Math.abs(h - want.h) > 0.05) { + clipped[`soil_add_${n}_height`] = Number(h.toFixed(1)); + showToast( + `흙막이 ${n} 높이는 ${h < want.h ? "형태 한계로" : "최소"} ${h.toFixed(1)}m` + + `${h < want.h ? "까지만" : "로"} 적용됩니다.`, + "error", + ); + } + want.h = Number(h.toFixed(1)); + } + const sent = soilSent.get(key) ?? ctx.revetOffset.adjustFor(owner, role); + if ( + (want.h !== undefined && want.h !== sent.h) || + (want.m !== undefined && want.m !== sent.m) + ) { + ctx.revetOffset.update(owner.chainage_m, role, want); + soilSent.set(key, { h: want.h ?? sent.h, m: want.m ?? sent.m }); + } + const span = { + lengthM: value(n, "length"), + beforeM: value(n, "before"), + afterM: value(n, "after"), + }; + if (!ctx.structureSpan || Object.values(span).every((v) => v === undefined)) continue; + const current = ctx.structureSpan.tierValuesFor(owner, role); + const next = applySpanPatch(current, span); + if ( + (["lengthM", "beforeM", "afterM"] as const).some((k) => Math.abs(next[k] - current[k]) > 1e-6) + ) + ctx.structureSpan.updateTier(owner, role, span); + } + // 폼에 되돌림은 이 변경을 패널이 옵션에 담은 뒤 — 지금 되돌리면 옛 옵션으로 폼을 다시 세움 + if (Object.keys(clipped).length) + queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped)); +} + /** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */ const ADJUST_OWNED = /_revet_(height_m|form)$/; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 0002fef70..b9d9450d4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -78,9 +78,9 @@ export interface B06StructuresPanelDeps { detail?: () => SectionDetailResponse | null; /** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본 * 높이도 이 값을 쓴다(조정창 표시값과 같다). */ - wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null; + wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null; /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ - wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null; + wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null; /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts index e94db0ef9..00e4c49d5 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts @@ -140,8 +140,8 @@ export function withSpecDefaults( detail: SectionDetailResponse | null, chainageM: number, types: StructureType[], - wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, - wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, + wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null, + wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null, ): Merged | undefined { const owner = detail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, @@ -160,5 +160,16 @@ export function withSpecDefaults( const drawnHeight = wallHeight?.(chainageM, prefix); if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); } + // 다단 n 단 조작값 → 흙막이 n 컨테이너(54-10) — 앞 컨테이너가 비었으면 자리만 둠(차례 지킴) + for (let i = 0; i < 9 && !culvert.hidden_pipe; i++) { + const form = wallForm?.(chainageM, `extra${i}`); + const height = wallHeight?.(chainageM, `extra${i}`); + if (!form && height == null) continue; + for (let n = 1; n <= i; n++) + if (!Object.keys(merged).some((key) => key.startsWith(`soil_add_${n}_`))) + merged[`soil_add_${n}_structure`] = ""; + if (form) merged[`soil_add_${i + 1}_form`] = form; + if (height != null) merged[`soil_add_${i + 1}_height`] = Number(height.toFixed(1)); + } return merged; } diff --git a/resources/tester/helper_54_10_soil_tiers.cjs b/resources/tester/helper_54_10_soil_tiers.cjs new file mode 100644 index 000000000..a2d7ae6db --- /dev/null +++ b/resources/tester/helper_54_10_soil_tiers.cjs @@ -0,0 +1,216 @@ +/* 다단 흙막이 ↔ 흙막이 컨테이너(PLAN 54-10) 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * ① 폼 → 다단 그림(`applyPipeOptionsToCache`) ② 밖에서 정한 개수보다 많은 컨테이너 값은 가리기만(`createSpecForm`). + * 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_10_soil_tiers.py`. + */ +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(); + +// ── 화면 그릇 흉내 — 태그 · 클래스 · data 만 ───────────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + 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() {} + 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 toasts = []; +const STUBS = { + "@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./B06_Section_UI_Page_Ford_Controls": { + applyFordFormOptions: () => {}, + applyBoxFormOptions: () => {}, + }, + "../../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 out = {}; + +// ── ① 폼 → 다단 그림 ────────────────────────────────────────────────────── +const { applyPipeOptionsToCache } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"), +); +const adjusts = {}; +const updates = []; +const tiers = []; +const overrides = []; +const section = { + chainage_m: 100, + culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } }, +}; +const ctx = { + detail: () => ({ cross_sections: [section] }), + ford: {}, + box: {}, + revetOffset: { + adjustFor: (_s, role) => adjusts[role] ?? { x: 0, d: null, h: null, m: null }, + update: (_c, role, patch) => { + updates.push({ role, ...patch }); + const now = adjusts[role] ?? { x: 0, d: null, h: null, m: null }; + const m = patch.m === undefined ? now.m : patch.m; + // 기하 되받기(syncApplied) 흉내 — 형태 한계로 자름(돌쌓기(메) 2.0) + const h = + patch.h == null + ? patch.h === undefined + ? now.h + : null + : Math.min(patch.h, m === "돌쌓기(메)" ? 2.0 : 5.0); + adjusts[role] = { ...now, h, m }; + }, + }, + structureSpan: { + tierValuesFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }), + updateTier: (_s, key, patch) => tiers.push({ key, ...patch }), + }, + soilTiers: () => 2, + overrideOptions: (_c, values) => overrides.push(values), + refreshCard: () => {}, +}; +applyPipeOptionsToCache(ctx, 100, { + soil_add_1_form: "콘크리트", + soil_add_1_height: 1.8, + soil_add_1_length: 8, + soil_add_1_before: 3, + soil_add_1_after: 5, + soil_add_2_form: "돌쌓기(메)", + soil_add_2_height: 2.5, + soil_add_3_height: 1.2, // 지형이 못 세운 단 — 그림에 안 감 +}); +out.filled = { updates: updates.splice(0), tiers: tiers.splice(0), overrides: overrides.splice(0) }; +out.toasts = toasts.splice(0); +// 같은 값을 다시 보내면 다시 그리지 않음 +applyPipeOptionsToCache(ctx, 100, { + soil_add_1_form: "콘크리트", + soil_add_1_height: 1.8, + soil_add_2_form: "돌쌓기(메)", + soil_add_2_height: 2.0, +}); +out.same = { updates: updates.splice(0), tiers: tiers.splice(0) }; +// 비운 칸 = 자동(높이 · 형태) · 구간값은 그대로 +applyPipeOptionsToCache(ctx, 100, {}); +out.cleared = { updates: updates.splice(0), tiers: tiers.splice(0) }; +// 독립 기슭막이(관 숨김)는 다단을 안 만짐 +section.culvert.hidden_pipe = true; +applyPipeOptionsToCache(ctx, 100, { soil_add_1_height: 1.0 }); +out.hidden = updates.splice(0); + +// ── ② 개수보다 많은 컨테이너 값은 가리기만 · 값은 남김 ─────────────────────── +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")); +let count = 1; +const form = createSpecForm(null, { count: (c) => (c.id === "soil_add" ? count : undefined) }); +form.write(PIPE_SPEC, { + pipe_diameter_mm: 1000, + soil_add_1_form: "콘크리트", + soil_add_2_form: "돌망태", + soil_add_2_height: 1.5, +}); +const boxes = () => + form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length; +const soilKeys = () => + Object.keys(form.read()) + .filter((k) => k.startsWith("soil_add_")) + .sort(); +out.form = { shown1: boxes(), keys1: soilKeys() }; +count = 0; // 단 수 0 저장 — 옛 흙막이 값도 남음 +form.recount(); +out.form.shown0 = boxes(); +out.form.keys0 = soilKeys(); +count = 3; +form.recount(); +out.form.shown3 = boxes(); +out.form.keys3 = soilKeys(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_10_soil_tiers.py b/resources/tester/test_54_10_soil_tiers.py new file mode 100644 index 000000000..b5a3b4d51 --- /dev/null +++ b/resources/tester/test_54_10_soil_tiers.py @@ -0,0 +1,92 @@ +"""다단 흙막이 ↔ 흙막이 컨테이너 (PLAN 54-10). + +짝: `B06_Section/B06_Section_UI_Page_Pipe_Options.ts`(컨테이너 값 → 다단 조정값 · 단별 구간값) · +`A00_Common/design/design_structure_form.ts`(개수보다 많은 컨테이너는 가리기만 · 값은 남김) · +`B06_Section/B06_Section_UI_Adjust_Dock.ts`(다단 벽 고르기 → 그 단의 흙막이 컨테이너). +실제 TS 를 `helper_54_10_soil_tiers.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = Path(HERE).resolve().parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_54_10_soil_tiers.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def extras(updates: list[dict]) -> list[dict]: + return [u for u in updates if u["role"].startswith("extra")] + + +def test_컨테이너_형태_높이가_그_단_조정값으로(out) -> None: + assert extras(out["filled"]["updates"]) == [ + {"role": "extra0", "h": 1.8, "m": "콘크리트"}, + {"role": "extra1", "h": 2.0, "m": "돌쌓기(메)"}, + ] + + +def test_지형이_못_세운_단은_그림에_안_감(out) -> None: + roles = {u["role"] for u in out["filled"]["updates"]} + assert "extra2" not in roles + + +def test_높이_한도는_형태_규칙_그대로_폼에_되돌림(out) -> None: + assert out["filled"]["overrides"] == [{"soil_add_2_height": 2}] + assert out["toasts"] == ["흙막이 2 높이는 형태 한계로 2.0m까지만 적용됩니다."] + + +def test_길이_전_후는_단별_구간값(out) -> None: + assert out["filled"]["tiers"] == [{"key": "extra0", "lengthM": 8, "beforeM": 3, "afterM": 5}] + + +def test_같은_값은_다시_그리지_않음(out) -> None: + assert extras(out["same"]["updates"]) == [] + assert out["same"]["tiers"] == [] + + +def test_비운_칸은_자동_구간값은_그대로(out) -> None: + assert extras(out["cleared"]["updates"]) == [ + {"role": "extra0", "h": None, "m": None}, + {"role": "extra1", "h": None, "m": None}, + ] + assert out["cleared"]["tiers"] == [] + + +def test_독립_기슭막이는_다단을_안_만짐(out) -> None: + assert extras(out["hidden"]) == [] + + +def test_개수보다_많은_컨테이너는_가리고_값은_남김(out) -> None: + form = out["form"] + kept = ["soil_add_1_form", "soil_add_2_form", "soil_add_2_height"] + assert (form["shown1"], form["keys1"]) == (1, kept) + assert (form["shown0"], form["keys0"]) == (0, kept) # 단 수 0 저장에도 남음 + assert form["shown3"] == 3 and form["keys3"][:3] == kept + + +def test_다단_벽_고르기는_흙막이_컨테이너로() -> None: + dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") + assert 'fieldset[data-spec-container="soil_add"]' in dock + assert "panelRoot.dataset.extraIndex" in dock + panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text( + encoding="utf-8" + ) + assert "root.dataset.extraIndex" in panel diff --git a/resources/tester/ui_checks/54-10_다단_흙막이.js b/resources/tester/ui_checks/54-10_다단_흙막이.js new file mode 100644 index 000000000..1e1a7c197 --- /dev/null +++ b/resources/tester/ui_checks/54-10_다단_흙막이.js @@ -0,0 +1,107 @@ +/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너 + * 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18) + * 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)" + * look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이) + * wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look + * set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look + * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - + * 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안) + */ +(async () => { + const ARGS = window.__m5410 || { mode: "look" }; + const PIPE = ARGS.pipe || "배관 2"; + 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 visible = (n) => n.offsetParent !== null && !n.closest("[hidden]"); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])]; + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const station = () => + items() + .find((l) => l.textContent.trim().endsWith(PIPE)) + ?.textContent.trim() + .split(PIPE)[0]; + const card = () => + [...document.querySelectorAll(".b06-cross-card")].find((c) => + c.textContent.startsWith(station()), + ); + const pickPipe = async () => { + await until(() => items().length > 0); + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + const li = items().find((l) => l.textContent.trim().endsWith(PIPE)); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(800); + }; + const tierRow = () => + spec() + ?.querySelector('[data-spec-container="outlet"]') + ?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const extraShapes = () => + [...(card()?.querySelectorAll(".b06-chart__culvert-revet") ?? [])] + .map((s) => s.querySelector("title")?.textContent ?? "") + .filter((t) => t.startsWith("추가")) + .map((t) => /^(.*? H=[\d.]+m)/.exec(t)?.[1] ?? t.slice(0, 40)); + const look = () => { + const boxes = soils(); + const rect = (b) => b.getBoundingClientRect(); + return { + station: station(), + soilCount: boxes.length, + active: boxes + .map((b, i) => (b.classList.contains("is-active") ? i : -1)) + .filter((i) => i >= 0), + activeInView: boxes + .filter((b) => b.classList.contains("is-active")) + .map((b) => rect(b).top >= 0 && rect(b).bottom <= innerHeight), + values: boxes.map((b) => + Object.fromEntries( + [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem.split(".")[1], + (n.querySelector("select, input, output") ?? {}).value ?? "", + ]), + ), + ), + extraGroupShown: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + ).length, + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + walls: extraShapes(), + }; + }; + await pickPipe(); + const out = { mode: ARGS.mode, pipe: PIPE }; + if (ARGS.mode === "wall") { + const hits = [...(card()?.querySelectorAll(".b06-chart__culvert-revet-hit") ?? [])].filter( + (h) => h.querySelector("title")?.textContent.startsWith("추가"), + ); + out.hits = hits.length; + hits[ARGS.n]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(1500); + } + if (ARGS.mode === "set") { + const field = soils()[ARGS.n]?.querySelector(`[data-spec-item="soil_add.${ARGS.item}"]`); + const el = field?.querySelector("select, input"); + out.found = !!el; + if (el) { + el.value = ARGS.value; + el.dispatchEvent(new Event("change", { bubbles: true })); + } + await w(1500); + } + if (ARGS.mode === "tier+" || ARGS.mode === "tier-") { + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click(); + await w(1500); + } + Object.assign(out, look()); + return JSON.stringify(out); +})();