diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 875291406..b9656a297 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -59,6 +59,8 @@ export interface SpecFormHooks { * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; + /** 컨테이너 칸 끝에 덧붙이는 칸(54-27 배관 유출 「추가 흙막이(단)」 · 값은 저장 옵션 밖). */ + fields?: (c: SpecContainer, values: Record) => HTMLElement[]; /** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) · * null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */ shown?: ( @@ -222,7 +224,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { ); return wrap; }); - box.body.append(grid(...fields)); + box.body.append(grid(...fields, ...(hooks.fields?.(c, values) ?? []))); if (c.required) { const slot = slotOf(c.id); // B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1) diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 357ab0857..ddbd00eb4 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -5,7 +5,8 @@ * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). - * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만. + * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) · + * 쓰기는 횡단 보기 단 수 제어로만(`setPipeExtraCount` · 54-27). * ========================================================================== */ import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; @@ -25,6 +26,23 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe extraCountSource = source; } +/** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */ +let extraCountSink: ((section: CrossSection, count: number) => void) | null = null; +let crossOpener: (() => Promise) | null = null; +export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void { + extraCountSink = sink; +} +export function setPipeCrossOpener(opener: () => Promise): void { + crossOpener = opener; +} + +/** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */ +export async function setPipeExtraCount(chainageM: number | null, count: number): Promise { + if (!extraCountSink) await crossOpener?.(); + const found = ownerSection(chainageM); + if (found) extraCountSink?.(found.owner, count); +} + /** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ type InletChoice = NonNullable["inlet_structure"]; let inletSource: ((section: CrossSection) => InletChoice) | null = null; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 00144aa80..b260c25b4 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -75,6 +75,7 @@ import { import type { DesignPageHost } from "./design_page"; import { pipeExtraCount, + setPipeExtraCountSink, setPipeExtraCountSource, setPipeInletSource, setPipeSectionsSource, @@ -132,6 +133,7 @@ export async function mountCrossView( ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; + let wallAdjustTimer = 0; const structuresPanel = createB06StructuresPanel({ projectId, shared, @@ -294,9 +296,21 @@ export async function mountCrossView( projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), onExtraCount: () => structuresPanel.facility.recount(), + // 흙막이 자리를 옮기면 관 길이가 바뀜 — 좌측 「관 길이 (m)」 다시(54-28) · 끌기 · 되받기는 한 번으로 모음 + onWallAdjust: () => { + window.clearTimeout(wallAdjustTimer); + wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120); + }, }); // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); + // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 + setPipeExtraCountSink((owner, count) => { + const picked = stationControls.revetOffset.highlightFor(owner) ?? ""; + if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count) + stationControls.revetOffset.select(owner.chainage_m, "outlet"); + stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet"); + }); // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); @@ -527,6 +541,7 @@ export async function mountCrossView( }); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); + window.clearTimeout(wallAdjustTimer); sectionView.dispose(); }); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index e9614496a..b06de10cf 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -69,7 +69,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; -import { setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). @@ -196,6 +196,8 @@ export async function mountProfileView( let currentSectionDetail: SectionDetailResponse | null = null; // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null); + // 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27) + setPipeCrossOpener(() => shared.ensureView("cross")); /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ let corridorVisible = true; let routeReady = false; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 15d55e4c7..45a65767a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -40,8 +40,10 @@ import { INLET_REVET_KEYS, type InletStructureKind, labeled, + numberInput, optionalSelect, OUTLET_REVET_KEYS, + stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; @@ -61,6 +63,7 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, + setPipeExtraCount, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -298,7 +301,28 @@ export function createFacilityOptionsForm( } return drawnMemo; }; + // 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감 + const tiersInput = numberInput("1", "0", "횡단 설계 뒤"); + tiersInput.max = "9"; + const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0)); + tiersRow.dataset.specItem = "outlet.tiers"; + tiersRow + .querySelectorAll("button") + .forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`)); + const paintTiers = (values: Record): HTMLElement => { + const count = pipeExtraCount(chainage); + tiersInput.value = count === null ? "" : String(count); + tiersInput.disabled = count === null; + tiersRow.hidden = !!values.structure && values.structure !== "기슭막이"; + return tiersRow; + }; + tiersInput.addEventListener("change", () => { + const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0))); + // 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림 + void setPipeExtraCount(chainage, count).then(() => pipeForm.recount()); + }); const pipeForm = createSpecForm(specLinks, { + fields: (c, values) => (c.id === "outlet" ? [paintTiers(values)] : []), shown: (c, item, values, index) => { if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) { const drawn = drawnNow(); @@ -307,6 +331,8 @@ export function createFacilityOptionsForm( : { value: "", tag: "자동(지형 규칙)" }; } if (values[item.id]) return null; + // 흙막이 상자 구조 = 표 01 흙막이 하나(54-27) + if (c.id === "soil_add" && item.id === "structure") return { value: "기슭막이", tag: "기본" }; if (item.id !== "form") return byDefault(branchOf(item, values)?.key); const basin = c.id === "inlet" && values.structure === "집수정"; const drawn = drawnNow(); diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index c03d8bade..b4b863758 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -57,6 +57,12 @@ const SIDE_ROWS = [ ".b06-structure-panel__struct--wall-height", ].join(", "); +/** 배관 유출 칸 — 읽기 줄(54-1) · 단 수 줄(폼 「추가 흙막이(단)」 칸이 맡음 · 54-27)은 안 받음. */ +const PIPE_OUTLET_ROWS = SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "").replace( + ".b06-structure-panel__struct--tier, ", + "", +); + /** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { return !!target?.closest("fieldset") && !target.closest("[hidden]"); @@ -156,11 +162,7 @@ function relocateSideRows(panelRoot: HTMLElement): void { ] : undefined; if (soil) { - moveRows( - panelRoot, - SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""), - ownerSlot, - ); + moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot); setGroupLabel(soil, ""); soil.classList.add("is-active"); soil.scrollIntoView({ block: "nearest" }); @@ -172,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement): void { moveRows(panelRoot, EXTRA_ROWS, target); // 단 수·옵션은 그 단이 딸린 부모 벽의 값이라 부모 칸에 그대로 세운다 — 다단을 // 고를 때 그 두 행이 사라지던 것을 막는다(2026-08-30 사용자). - if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot); + if (slotUsable(ownerSlot)) + moveRows(panelRoot, ownerSlot === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS, ownerSlot); } else { if (!slotUsable(target)) return; - // 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1) - const rows = - target === sideSlots.outlet - ? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "") - : SIDE_ROWS; + // 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내) · 단 수 줄을 안 받음(54-1 · 54-27) + const rows = target === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS; moveRows(panelRoot, rows, target); } // 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3). diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 3eb0fe8a4..bdaabfaa6 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -44,8 +44,6 @@ export interface StructurePanelDeps { setForm: (key: RevetKey, form: string) => void; /** 자동 자리로 되돌린다(4축 모두). */ reset: (key: RevetKey) => void; - /** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */ - pipeLengthM: () => number | null; /** 창을 닫는다 = 구조물 선택 해제. */ close: () => void; /** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */ @@ -155,16 +153,13 @@ export function buildStructurePanel( const title = document.createElement("span"); title.className = "b06-structure-panel__title"; - // 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하. + // 끄는 동안 미리 보기 글만 — 관 길이 · 좌우 · 상하 자동 자리 글은 뺌(54-28). const value = document.createElement("div"); value.className = "b06-structure-panel__value"; // 사본의 읽기 줄은 폼 칸으로 옮겨 붙는다 — 끌어 맞춘 좌우 · 상하가 폼에서도 보인다(24-4a). if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout"); - const pipeLine = document.createElement("span"); - const lateralLine = document.createElement("span"); - const slopeLine = document.createElement("span"); const dragLine = dragReadout(); - value.append(pipeLine, lateralLine, slopeLine, dragLine); + value.append(dragLine); let current: RevetKey | null = null; const act = (run: (key: RevetKey) => void) => () => { @@ -630,41 +625,6 @@ export function buildStructurePanel( "is-hidden", !revetWall && linkState === null && currentSpan === null, ); - const adjust = deps.adjustFor(key); - // 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥, - // 상하는 위/아래로 적는다. - const pipeLength = deps.pipeLengthM(); - pipeLine.classList.toggle("is-hidden", pipeLength === null); - pipeLine.textContent = - pipeLength === null ? "" : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m`; - lateralLine.textContent = - `${L("B06_Cross_Lateral_Label")} ` + - (Math.abs(adjust.x) > 1e-9 - ? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m` - : L("B06_Cross_Revet_Auto")); - // d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값 - // (배관 벽 — 2026-08-23). 0 = 성토선 0 지점. - slopeLine.textContent = - `${L("B06_Cross_Slope_Label")} ` + - (adjust.d == null - ? L("B06_Cross_Revet_Auto") - : Math.abs(adjust.d) > 1e-9 - ? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m` - : "0m"); - // 집수정은 벽 값이 아니라 **집수정 자리**를 적는다 — 끌어 맞춘 위치가 폼 칸에서도 보인다(24-4a). - if (isBasin) { - const basin = deps.basinAdjustFor(); - lateralLine.textContent = `${L("B06_Cross_Lateral_Label")} ${ - basin.lateralM > 1e-9 - ? `${L("B06_Cross_Revet_Outward")} ${basin.lateralM.toFixed(1)}m` - : L("B06_Cross_Revet_Auto") - }`; - slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ${ - basin.slopeM > 1e-9 - ? `${L("B06_Cross_Revet_DirDown")} ${basin.slopeM.toFixed(1)}m` - : L("B06_Cross_Revet_Auto") - }`; - } // 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면 // 브라우저가 잘라 버린다. restoreScroll(); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index d362bc1c1..d848011e6 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -184,7 +184,6 @@ export function createCrossSectionCard( let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; - let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 // 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이 // 배관(1m)과 다르다(0.1m — 2026-08-29 사용자). @@ -483,13 +482,7 @@ export function createCrossSectionCard( toDisplayY, ); } - // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점 - // 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24). - // 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김. - culvertPipeLengthM = - isLinkedCulvert || culvertLayout?.culvert.hidden_pipe - ? null - : (culvertLayout?.pipe.lengthM ?? null); + // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24). culvertInletIsBasin = !!culvertLayout?.basin; culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true; if (culvertLayout) { @@ -621,7 +614,6 @@ export function createCrossSectionCard( drawnWallKeys: () => [...culvertWallSpecs.keys()], formOfWall, appliedD: () => culvertAppliedD, - pipeLengthM: () => culvertPipeLengthM, inletIsBasin: () => culvertInletIsBasin, hiddenPipe: () => culvertHiddenPipe, inletOptions: () => culvertInletOptions, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 7e25d81a3..d1bb26b67 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -64,7 +64,6 @@ export interface StructurePanelContext { formOfWall: (key: RevetKey) => string; /** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */ appliedD: () => Map; - pipeLengthM: () => number | null; inletIsBasin: () => boolean; /** 기준벽이 독립 기슭막이(관 없는 숨김 세트)인가 — 조정창 걸음 분류에 쓴다. */ hiddenPipe: () => boolean; @@ -103,7 +102,6 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe setForm: (key, form) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: form }), heightFor: (key) => ctx.heightOfWall(key), reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key), - pipeLengthM: () => ctx.pipeLengthM(), close: () => { const active = ctx.activeRevet(); if (active) ctx.toggleRevet(active); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 0b9d4b225..8dcaee654 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -63,6 +63,8 @@ export interface StationControlDeps { onSaveError?: (message: string) => void; /** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */ onExtraCount?: () => void; + /** 벽 조작값이 바뀜 — 좌측 관 길이(자동)를 다시 셈(54-28). */ + onWallAdjust?: () => void; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ @@ -206,6 +208,7 @@ export function createStationControls(deps: StationControlDeps): StationControls if (isDefaultAdjust(value)) delete bucket[role]; else bucket[role] = { ...value }; design.revet_adjust = bucket; + deps.onWallAdjust?.(); }; const axisEq = (a: number | null, b: number | null): boolean => a == null || b == null ? a === b : Math.abs(a - b) < 1e-9; diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs new file mode 100644 index 000000000..d32dc24b0 --- /dev/null +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -0,0 +1,203 @@ +/* 배관 유출 「추가 흙막이(단)」 칸(PLAN 54-27) 단위검증 헬퍼 — 실제 시설 폼(`createFacilityOptionsForm`)을 + * 그 자리에서 트랜스파일해 Node 로 돌린다. 단 수 읽기 · 쓰기(`design_structure_pipe_calc`)만 흉내 — + * 쓰면 그 수가 곧 지형이 세운 단 수(한도 2). 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_27_soil_tier_field.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.parentElement = null; + this.hidden = false; + this.disabled = false; + this._classes = new Set(); + this._listeners = {}; + 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(""); + } + get childElementCount() { + return this.children.length; + } + append(...kids) { + kids.forEach((k) => { + const node = typeof k === "string" ? { textContent: k } : k; + if (node.parentElement) + node.parentElement.children.splice(node.parentElement.children.indexOf(node), 1); + node.parentElement = this; + this.children.push(node); + }); + } + replaceChildren(...kids) { + this.children.forEach((k) => (k.parentElement = null)); + this.children = []; + this.append(...kids); + } + add(kid) { + this.append(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + dispatchEvent(event) { + (this._listeners[event.type] ?? []).forEach((fn) => fn(event)); + } + 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), + createComment: () => new FakeNode("#comment"), +}; +globalThis.Event = function (type) { + this.type = type; +}; +globalThis.queueMicrotask = (fn) => fn(); +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +// 단 수 자리 흉내 — 쓰면 지형 한도(2)까지만 섬 +const LIMIT = 2; +let stored = 0; +const writes = []; +const PIPE_CALC = { + guardHeightHint: () => "", + pipeDrawn: () => null, + pipeLengthM: () => null, + pipeExtraCount: () => Math.min(stored, LIMIT), + setPipeExtraCount: (chainage, count) => { + writes.push([chainage, count]); + stored = count; + return Promise.resolve(); + }, +}; +const STUBS = { + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "../A00_Common/design/design_structure_pipe_calc": PIPE_CALC, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { + buildSummaryRow: () => { + const root = new FakeNode("div"); + const label = new FakeNode("span"); + label.className = "b05-structure__summary-label"; + root.append(label); + return { root, set: () => {}, bind: () => {} }; + }, + }, +}; + +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 { createFacilityOptionsForm } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"), +); +const form = createFacilityOptionsForm(); +form.setChainage(100); +form.setFacility("pipe", { pipe_diameter_mm: 1000 }); + +const spec = form.root.querySelector(".b05-structure__spec"); +const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id); +const tierRow = () => + boxesOf("outlet")[0] + ?.querySelectorAll("label") + .find((l) => l.dataset.specItem === "outlet.tiers"); +const tierInput = () => tierRow()?.querySelector("input"); +const snap = () => ({ + value: tierInput()?.value ?? null, + boxes: boxesOf("soil_add").length, + structures: boxesOf("soil_add").map((b) => b.querySelector("select")?.children[0]?.textContent), +}); + +async function main() { + const out = { label: tierRow()?.children[0]?.textContent ?? null, start: snap() }; + for (const n of ["2", "1", "5", "0"]) { + const input = tierInput(); + input.value = n; + input.dispatchEvent(new Event("change")); + await Promise.resolve(); + await Promise.resolve(); + out[`set${n}`] = snap(); + } + // 횡단 조정에서 바꿈 → 좌측 칸 · 상자 따라감(B06 이 recount 를 부름) + stored = 2; + form.recount(); + out.fromCross = snap(); + out.writes = writes; + // 다른 구조(옛 값)면 칸을 가림 + form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" }); + out.otherHidden = tierRow()?.hidden ?? null; + process.stdout.write(JSON.stringify(out)); +} +main().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py new file mode 100644 index 000000000..b6fe0df3e --- /dev/null +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -0,0 +1,78 @@ +"""배관 유출 「추가 흙막이(단)」 칸 (PLAN 54-27). + +짝: `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`(유출 상자 안 단 칸 · 흙막이 상자 구조 「흙막이 (기본)」) · +`A00_Common/design/design_structure_pipe_calc.ts`(`setPipeExtraCount` = 횡단 조정 단 수와 같은 자리). +실제 TS 를 `helper_54_27_soil_tier_field.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_54_27_soil_tier_field.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_유출_상자_안에_단_칸이_있음(out) -> None: + assert out["label"] == "추가 흙막이(단)" + assert out["start"] == {"value": "0", "boxes": 0, "structures": []} + + +def test_단_수_N_이면_흙막이_상자_N(out) -> None: + assert (out["set2"]["value"], out["set2"]["boxes"]) == ("2", 2) + assert (out["set1"]["value"], out["set1"]["boxes"]) == ("1", 1) + assert (out["set0"]["value"], out["set0"]["boxes"]) == ("0", 0) + + +def test_지형_한도를_넘으면_세운_만큼(out) -> None: + assert (out["set5"]["value"], out["set5"]["boxes"]) == ("2", 2) + + +def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None: + assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]] + + +def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None: + assert (out["fromCross"]["value"], out["fromCross"]["boxes"]) == ("2", 2) + + +def test_늘어난_흙막이_상자_구조는_흙막이_기본(out) -> None: + assert out["set2"]["structures"] == ["흙막이 (기본)", "흙막이 (기본)"] + + +def test_흙막이_아닌_구조면_칸을_가림(out) -> None: + assert out["otherHidden"] is True + + +def _src(rel: str) -> str: + with open(os.path.join(HERE, "..", "..", rel), encoding="utf-8") as f: + return f.read() + + +def test_자동_자리_읽기_글이_없음() -> None: + """54-28 — 관 길이 · 좌우 바깥 · 상하(사면) 자동 자리 글을 만드는 뿌리(문구 · 줄)가 없음.""" + locale = _src("ui_template/ui_template_locale_b2.ts") + for key in ("B06_Cross_Revet_Auto", "B06_Cross_Revet_Pipe", "B06_Cross_Lateral_Label"): + assert key not in locale + assert "lateralLine" not in _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts") + + +def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None: + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert '".b06-structure-panel__struct--tier, ",\n "",' in dock diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 7dec8e9ff..9ab406183 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -243,10 +243,6 @@ export const ui_locales_b2 = { ], B06_Cross_Revet_Label: ["기슭막이", "Revetment"], B06_Cross_Revet_Close: ["조정창 닫기", "Close adjuster"], - B06_Cross_Revet_Auto: ["자동 자리", "Solved position"], - B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"], - B06_Cross_Revet_Outward: ["바깥", "outward"], - B06_Cross_Revet_Inward: ["안쪽", "inward"], B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"], B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"], B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], @@ -259,8 +255,6 @@ export const ui_locales_b2 = { B06_Cross_Revet_Extra: ["기슭막이(추가 {n})", "Revetment (extra {n})"], B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"], B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"], - B06_Cross_Revet_DirUp: ["위", "up"], - B06_Cross_Revet_DirDown: ["아래", "down"], B06_Cross_Revet_Limit_Up: [ "사면 위로는 여기까지입니다 — 최소 성토고·토피(역경사) 한계", "Cannot move further up the slope — min fill height / cover limit", @@ -271,8 +265,6 @@ export const ui_locales_b2 = { ], B06_Cross_Height_Label: ["높이", "Height"], B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"], - B06_Cross_Lateral_Label: ["좌우", "Lateral"], - B06_Cross_Slope_Label: ["상하(사면)", "Along slope"], B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"], B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"], B06_Cross_Height_Limit: [