From 6f35889b4dfff89859d562e51788bffed4ef7495 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:32:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(structure):=20B05=20=C2=B7=20B06=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?1=EC=B0=A8=20=EC=9A=94=EC=B2=AD=20=EB=B0=98=EC=98=81(54-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잇기 단추 글 「고르기」 → 「선택」(명세 · 정의 그룹 · 종류 단위 줄 공용) - 「지난 정의 값」 상자 모든 종류에서 숨김 — 값은 파일에 그대로 · 코드 남김 - 관 길이 = 보여 주는 칸(손 입력 칸 아님 · 정수 · 횡단 전 「횡단 설계 뒤 셈」) - 유입 집수정 = 길이 · 기준측점 전 · 후 칸 숨김 · 옛 값은 병합이 지킴 - 배관 유출 칸에 읽기 줄(관 길이 · 좌우 자동 자리 안내) 안 옮김 - [흙막이 추가] 없앰 — 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N(늘 보임 · 줄면 끝에서 빠짐 · 잇기 함께 풂 · 이름 흙막이) - 단 수 소스 = 횡단 세션 값 → 캐시 patch · 종단 보기도 같은 횡단 자료(B05 = B06) - 지형이 단을 못 세워 되돌릴 때 캐시 patch 도 맞춤(옛 수로 돌아가던 흠) - 집계표 선택 컨테이너 이름 짝(서버) 흙막이 · 시험 기대값 · 52-1 · 52-13 화면 시나리오 고침 · 54-1 화면 점검 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_structure_form.ts | 39 +++-- A00_Common/design/design_structure_pipe.ts | 26 ++-- .../design/design_structure_pipe_calc.ts | 29 +++- A00_Common/design/design_view_cross.ts | 5 +- A00_Common/design/design_view_profile.ts | 3 + .../B05_Profile_UI_Drainage_Facility.ts | 21 ++- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 4 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 3 +- .../B05_Profile_UI_Structures_Summary_Row.ts | 2 +- B06_Section/B06_Section_UI_Adjust_Dock.ts | 7 +- .../B06_Section_UI_Page_Station_Controls.ts | 11 ++ .../B06_Section_UI_Page_Structures_Panel.ts | 3 + M02_MasterTemplete/M02_Table_Fill_Spec.py | 2 +- resources/tester/test_52_1b_pipe_links.py | 2 +- .../tester/test_52_structure_spec_pipe.py | 5 +- .../tester/test_b05_facility_options_merge.py | 2 +- .../test_b05_structures_form_summary.py | 2 +- resources/tester/ui_checks/52-13_예외.js | 26 ++-- .../tester/ui_checks/52-1_배관_컨테이너.js | 34 ++--- .../tester/ui_checks/54-1_배관_좌측패널.js | 134 ++++++++++++++++++ 20 files changed, 279 insertions(+), 81 deletions(-) create mode 100644 resources/tester/ui_checks/54-1_배관_좌측패널.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 6e63dbbe9..b7ece0aed 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -55,6 +55,9 @@ export interface SpecFormHooks { /** 숫자 칸 빈 칸 안내. */ hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; onChange?: (c: SpecContainer, item: SpecItem) => void; + /** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 · + * [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */ + count?: (c: SpecContainer) => number | null | undefined; } export interface SpecForm { @@ -75,6 +78,8 @@ export interface SpecForm { /** 잇기 값이 바뀌었을 때 줄을 다시 그림. */ paintLinks: () => void; refresh: () => void; + /** 밖에서 정한 개수(`hooks.count`)가 바뀌었을 때 — 맞춰 그리고 바뀌었으면 change 알림. */ + recount: () => void; } export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm { @@ -122,11 +127,12 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { - input.type = "text"; - input.readOnly = true; - input.value = hooks.auto?.(c, item, state) || "—"; - input.title = "자동 계산 — 고치지 않음"; - return input; + // 보여 주는 칸(54-1) — 손 입력 칸 아님 + const shown = document.createElement("output"); + shown.className = "b05-structure__auto"; + shown.textContent = hooks.auto?.(c, item, state) || "—"; + shown.title = "자동 계산"; + return shown; } input.value = values[item.id] ?? ""; return stepper(input, item.step ?? 0.1); @@ -187,7 +193,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { rows.push({ key, set: row.set }); box.body.append(row.root); } - if (!c.required) { + if (!c.required && hooks.count?.(c) === undefined) { const remove = document.createElement("button"); remove.type = "button"; remove.className = "b05-route__irregular-btn"; @@ -211,13 +217,25 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return box.root; }; - function paint(): void { + /** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */ + 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); + while (list.length < n) list.push({}); + return true; + }; + + function paint(quiet = true): void { rows.length = 0; const boxes: HTMLElement[] = []; + const fitted: SpecContainer[] = []; 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))); - if (c.required || (c.max && list.length >= c.max)) continue; + if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; const add = document.createElement("button"); add.type = "button"; add.className = "b05-route__irregular-btn b05-structure__spec-add"; @@ -232,6 +250,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { boxes.push(add); } root.replaceChildren(...boxes); + // 개수가 밖에서 바뀌었으면 값 변경으로 알림(불러오기 때는 안 함) + if (!quiet) fitted.forEach((c) => hooks.onChange?.(c, c.items[0])); } return { @@ -257,6 +277,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const now = links?.get() ?? {}; rows.forEach((row) => row.set(now[row.key] ?? null)); }, - refresh: paint, + refresh: () => paint(), + recount: () => paint(false), }; } diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 64e398291..37061d3e9 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -2,8 +2,9 @@ * design_structure_pipe.ts * 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로. * - * 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) · - * 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이(선택 · 개수 = 유출 + * 「추가 기슭막이(단)」 · 54-1) · 도수로/돌붙임(선택 · 여러 개). 집수정 = 길이 · 전 · 후 없음(54-1). + * 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」 * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. @@ -40,10 +41,13 @@ function guardItems(keys?: Record>): SpecItem[] { ? { item: "structure", map: Object.fromEntries( - Object.entries(keys).map(([structure, k]) => [ - structure, - { key: k[field], choices: choices?.[structure] }, - ]), + // 키 없는 갈래 = 그 구조에 없는 칸(집수정 길이 · 전 · 후) — 가림 + Object.entries(keys) + .filter(([, k]) => k[field]) + .map(([structure, k]) => [ + structure, + { key: k[field], choices: choices?.[structure] }, + ]), ), } : undefined; @@ -121,13 +125,7 @@ export const PIPE_SPEC: StructureSpec = { ], }, ...guardItems({ - [BASIN]: { - form: "inlet_structure", - length: "inlet_basin_length_m", - height: "inlet_basin_height_m", - before: "inlet_basin_before_m", - after: "inlet_basin_after_m", - }, + [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), ], @@ -150,7 +148,7 @@ export const PIPE_SPEC: StructureSpec = { }, { id: "soil_add", - title: "흙막이 추가", + title: "흙막이", required: false, prefix: "soil_add", link: "gsoiladd", diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index d6432aabe..cf83b8aaa 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -5,6 +5,7 @@ * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). + * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만. * ========================================================================== */ import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; @@ -18,9 +19,16 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu sectionsSource = source; } -/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된 - * `pipe_length_m` · 그것도 없으면 null. */ -export function pipeLengthM(chainageM: number | null): number | null { +/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */ +let extraCountSource: ((section: CrossSection) => number) | null = null; +export function setPipeExtraCountSource(source: (section: CrossSection) => number): void { + extraCountSource = source; +} + +/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ +function ownerSection( + chainageM: number | null, +): { owner: CrossSection; sections: readonly CrossSection[] } | null { const sections = sectionsSource?.() ?? null; if (chainageM === null || !sections?.length) return null; const holders = sections.filter( @@ -30,11 +38,26 @@ export function pipeLengthM(chainageM: number | null): number | null { const owner = holders.reduce((a, b) => Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a, ); + return { owner, sections }; +} + +/** 관 길이(m) — 주인 측점 횡단 기하 값 · 못 세우면 저장된 `pipe_length_m` · 그것도 없으면 null. */ +export function pipeLengthM(chainageM: number | null): number | null { + const found = ownerSection(chainageM); + if (!found) return null; + const { owner, sections } = found; const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM; const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m; return typeof length === "number" && length > 0 ? length : null; } +/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 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; +} + /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 3acaa9e8e..8936e69fd 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -73,7 +73,7 @@ import { createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; -import { setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc"; import { bindDesignView, bindPipeOptions, @@ -281,7 +281,10 @@ export async function mountCrossView( ensureSampledWidth, projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), + onExtraCount: () => structuresPanel.facility.recount(), }); + // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 + setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); const sectionView = createSectionView( (chainageM, change) => { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 6cbbbb4d6..39413fa5d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -70,6 +70,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 type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). @@ -194,6 +195,8 @@ export async function mountProfileView( let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; + // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) + setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null); /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 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 c3ad245fe..ccf009ed6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -55,7 +55,11 @@ import { } from "../A00_Common/design/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; -import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc"; +import { + guardHeightHint, + pipeExtraCount, + pipeLengthM, +} from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, linkFields, @@ -215,6 +219,8 @@ export interface FacilityOptionsForm { paintLinks: () => void; /** 고른 관 측점 — 관 길이(자동) 계산에 씀. */ setChainage: (chainageM: number | null) => void; + /** 횡단 「추가 기슭막이(단)」이 바뀜 — 배관 흙막이 컨테이너 수를 다시 맞춤(54-1 · B06 이 부름). */ + recount: () => void; /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */ readOptions: () => Record; /** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존). @@ -265,8 +271,10 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); + return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); }, + // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) + count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), hint: (c, item, state) => { const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; @@ -480,6 +488,9 @@ export function createFacilityOptionsForm( chainage = chainageM; if (current === "pipe") pipeForm.refresh(); }, + recount() { + if (current === "pipe") pipeForm.recount(); + }, onInletStructureChange(handler) { inletStructureHandler = handler; }, @@ -499,7 +510,11 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; - if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups); + if (facility === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + pipeForm.write(PIPE_SPEC, options, pipeNames, groups); + } if (facility === "box_culvert") { // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index b2bdccdd4..598ab6754 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -26,10 +26,8 @@ export const FORM_OPTION_KEYS: Readonly> = { "pipe_diameter_mm", "inlet_type", "inlet_structure", - "inlet_basin_length_m", + // 집수정 길이 · 전 · 후는 폼에서 빠짐(54-1) — 옛 값 그대로 둠 "inlet_basin_height_m", - "inlet_basin_before_m", - "inlet_basin_after_m", ...revetKeys("inlet"), "outlet_type", ...revetKeys("outlet"), diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 320f151ef..312e0ca25 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -246,7 +246,8 @@ export function paintStale( ? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p))) : []; box.replaceChildren(); - box.hidden = !stale.length; + // 상자는 모든 종류에서 숨김(54-1) — 값은 파일에 그대로 + box.hidden = true; if (!stale.length) return; const legend = document.createElement("legend"); legend.textContent = "지난 정의 값"; diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index 5f2a06e13..296417088 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -34,7 +34,7 @@ export function buildSummaryRow(): SummaryRow { const pick = document.createElement("button"); pick.type = "button"; pick.className = "b05-route__irregular-btn"; - pick.textContent = "고르기"; + pick.textContent = "선택"; pick.dataset.b05Summary = "pick"; const clear = document.createElement("button"); clear.type = "button"; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 7395f61ff..672a2c3a0 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -147,7 +147,12 @@ function relocateSideRows(panelRoot: HTMLElement): void { if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot); } else { if (!slotUsable(target)) return; - moveRows(panelRoot, SIDE_ROWS, target); + // 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1) + const rows = + target === sideSlots.outlet + ? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "") + : SIDE_ROWS; + moveRows(panelRoot, rows, target); } // 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3). const group = target.closest("fieldset"); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index f34125645..0b9d4b225 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -61,6 +61,8 @@ export interface StationControlDeps { projectId: () => string | null; /** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */ onSaveError?: (message: string) => void; + /** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */ + onExtraCount?: () => void; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ @@ -366,6 +368,7 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }); refreshWithLinked(chainageM); + deps.onExtraCount?.(); }, syncCount: (chainageM, built, side = "outlet") => { const key = extraKey(chainageM, side); @@ -381,6 +384,14 @@ export function createStationControls(deps: StationControlDeps): StationControls persistRevetShifts(); persistExtraSpans(); persistExtraCounts(); + // 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1) + patchCachedDesign(chainageM, { + extra_wall_counts: { + outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0, + basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0, + }, + }); + deps.onExtraCount?.(); }, }; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index d45b00c53..59b988b01 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -137,6 +137,8 @@ export interface B06StructuresPanel { facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void; + /** 횡단 단 수가 바뀜 — 배관 흙막이 컨테이너 수를 맞춤(54-1). */ + recount: () => void; }; } @@ -463,6 +465,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), + recount: () => section.facility.recount(), }, overrideOptions: (chainageM, patch) => { const hit = pipeFacilities.find( diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index e92d13fab..6523e0926 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -19,7 +19,7 @@ from common_util.common_util_summary_item import GROUP_JOIN #: 종류 → 선택 컨테이너 (머리, 잇기, 제목) SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { "pipe": ( - ("soil_add", "gsoiladd", "흙막이 추가"), + ("soil_add", "gsoiladd", "흙막이"), ("chute_add", "gchuteadd", "도수로/돌붙임"), ), "ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),), diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py index c0cc03f99..87ce7e553 100644 --- a/resources/tester/test_52_1b_pipe_links.py +++ b/resources/tester/test_52_1b_pipe_links.py @@ -39,7 +39,7 @@ SOIL = { def test_컨테이너마다_한_건_잇기와_그_값(): item = {"type_id": "pipe", "options": {"pipe_diameter_mm": 1000, **SOIL}, "복사본": "pipe@40.0"} rows = container_rows(item, {"gsoiladd1": RV, "gchuteadd1": MS}) - assert [r["그룹"] for r in rows] == ["흙막이 추가 1", "흙막이 추가 2", "도수로/돌붙임 1"] + assert [r["그룹"] for r in rows] == ["흙막이 1", "흙막이 2", "도수로/돌붙임 1"] assert [r["복사본"] for r in rows] == [ "pipe@40.0__gsoiladd1", "pipe@40.0__gsoiladd2", diff --git a/resources/tester/test_52_structure_spec_pipe.py b/resources/tester/test_52_structure_spec_pipe.py index 3f9abdf5c..229b448b2 100644 --- a/resources/tester/test_52_structure_spec_pipe.py +++ b/resources/tester/test_52_structure_spec_pipe.py @@ -36,7 +36,7 @@ def test_컨테이너_다섯_필수_셋_선택_둘(out): ["pipe", "관", True], ["inlet", "관 보호공 (유입)", True], ["outlet", "관 보호공 (유출)", True], - ["soil_add", "흙막이 추가", False], + ["soil_add", "흙막이", False], ["chute_add", "도수로/돌붙임", False], ] @@ -58,7 +58,8 @@ def test_형태_선택지는_구조를_따름(out): def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out): back = out["back"] assert back["pipe_diameter_mm"] == 1000 - assert back["inlet_structure"] == "L" and back["inlet_basin_length_m"] == 2 + # 집수정 = 길이 · 전 · 후 칸 없음(54-1) — 폼이 안 적음(병합이 옛 값을 지킴) + assert back["inlet_structure"] == "L" and "inlet_basin_length_m" not in back assert back["outlet_revet_height_m"] == 2.5 # 표 01 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) assert "pipe_kind" not in back and "wing_wall_type" not in back diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py index 3370decec..c1a5b5c8b 100644 --- a/resources/tester/test_b05_facility_options_merge.py +++ b/resources/tester/test_b05_facility_options_merge.py @@ -94,7 +94,7 @@ def merged(tmp_path_factory) -> dict: def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None: options = merged["out"][0]["options"] assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음 - assert "inlet_basin_before_m" not in options # 폼 칸(24-4a) — 집수정을 걷으면 함께 지움 + assert options["inlet_basin_before_m"] == 1.5 # 집수정 전 — 폼에서 빠짐(54-1) · 옛 값 남음 assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김 # 표 01 명세(PLAN 52-1)가 뺀 옛 칸 = 폼이 모르는 칸 → 남음(지난 정의 값) assert options["inlet_basin_form"] == "□형(기본형)" diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 8dbe89ae8..eca79410f 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -80,7 +80,7 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "고르기", "해제") + assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "선택", "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] diff --git a/resources/tester/ui_checks/52-13_예외.js b/resources/tester/ui_checks/52-13_예외.js index f3c60e262..c027a8b5d 100644 --- a/resources/tester/ui_checks/52-13_예외.js +++ b/resources/tester/ui_checks/52-13_예외.js @@ -2,13 +2,13 @@ * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「배관 1」 「세월교 1」 · 로그인 상태 * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5213 = "단계";` 를 붙여 * cache → 저장 안 하고 B05 → B06 → B05 오가기(캐시 값 그대로) · 종류 바꾸기(배관 → BOX → 배관) - * pipe → 배관 길이 · 전 · 후 비움 + 흙막이 추가 셋 + 도수로/돌붙임 하나 → [저장] - * (orca reload) pipe_v → 다시 열기 · 흙막이 수 3 · 비운 칸 그대로 + * pipe → 배관 길이 · 전 · 후 비움 + 도수로/돌붙임 하나 → [저장](흙막이 = 유출 단 수 · [추가] 없음 · 54-1) + * (orca reload) pipe_v → 다시 열기 · 흙막이 수 = 저장 전 그대로 · 비운 칸 그대로 * bf → 258.12 를 BOX암거로(PUT) · 가로 비움 · 세월교 1 월류 폭 비움 → [저장] * (orca reload) bf_v → 다시 열기 · 오류 없음 * restore → 원래 관 지점으로 되돌림(PUT) · 초안 비움 * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) - * 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount 3 · inletBlank 빈 칸 셋 · restored true + * 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount pipe = pipe_v · soilAdd 0 · inletBlank 빈 칸 셋 · restored true * (배관 도수로/돌붙임 [추가] 는 하나 뒤에도 보임 — 「하나만」은 물넘이 포장 · 기타만 · 52-3 · 52-10) */ (async () => { @@ -66,7 +66,8 @@ await w(500); return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); }; - const items = () => [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem); + const items = () => + [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem); const point = async (lo, hi) => (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( (p) => p.chainage_m > lo && p.chainage_m < hi, @@ -89,21 +90,17 @@ } if (MODE === "pipe") { - sessionStorage.setItem("__5213_original", JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points)); + sessionStorage.setItem( + "__5213_original", + JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points), + ); await pick(/배관 1$/); await set("inlet", "structure", "기슭막이"); await set("inlet", "form", "돌망태"); await set("inlet", "length", ""); await set("inlet", "before", ""); await set("inlet", "after", ""); - for (let i = 0; i < 3; i++) { - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - } - for (let i = 0; i < 3; i++) { - await set("soil_add", "structure", "기슭막이", i); - await set("soil_add", "form", "콘크리트", i); - } + out.soilAdd = spec().querySelectorAll('[data-spec-add="soil_add"]').length; spec().querySelector('[data-spec-add="chute_add"]').click(); await w(200); await set("chute_add", "structure", "돌붙임"); @@ -180,7 +177,8 @@ clearDrafts(); sessionStorage.removeItem("__5213_original"); const now = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points; - out.restored = JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) === + out.restored = + JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) === JSON.stringify(original.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])); } diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index f0f72fee0..4153fc273 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -6,8 +6,8 @@ * orca eval --expression "window.__m521='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 · 관 길이 * orca eval --expression "window.__m521='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) - * 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 · - * soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 + * 기대 수치(edit): kinds 4(흙막이 = 유출 단 수 · [추가] 없음 · 54-1) · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · + * formSoil 6 · split 길이 7 · soilAddButtons 0 · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06) * 52-1b: edit 가 흙막이 추가 · 도수로/돌붙임 컨테이너도 이음 → verify 집계표 줄 = 처음 + 3 * (유입 잇기 1 · 컨테이너마다 1) @@ -120,33 +120,16 @@ out.splitAfter = ctl("inlet", "after").value; await set("inlet", "after", "4"); out.splitLength = ctl("inlet", "length").value; - // 선택 컨테이너 — 흙막이 추가 둘 → 첫째 뺌 · 도수로/돌붙임 하나 - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - await set("soil_add", "structure", "기슭막이", 0); - await set("soil_add", "form", "바자", 0); - await set("soil_add", "structure", "기슭막이", 1); - await set("soil_add", "form", "콘크리트", 1); - out.soilAddTwo = spec().querySelectorAll('[data-spec-container="soil_add"]').length; - out.soilFormsSoilAdd = labels("soil_add", "form").length; - box("soil_add", 0).querySelector('[data-spec-remove="soil_add"]').click(); - await w(300); - out.soilAddAfterRemove = [...spec().querySelectorAll('[data-spec-container="soil_add"]')].map( - (b, i) => [ - b.querySelector("legend").firstChild.textContent, - ctl("soil_add", "form", i).value, - ], - ); + // 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나 + out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length; + out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length; spec().querySelector('[data-spec-add="chute_add"]').click(); await w(200); await set("chute_add", "structure", "돌붙임"); out.chuteChoices = labels("chute_add", "structure"); // 52-1b — 컨테이너마다 구조물 조합 잇기(집계표 한 줄씩) - await pickLink("soil_add"); await pickLink("chute_add"); - out.linkContainers = [linkName("soil_add"), linkName("chute_add")]; + out.linkContainers = [linkName("chute_add")]; // 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂 await pickLink("inlet"); out.linkInlet = linkName("inlet"); @@ -179,7 +162,7 @@ linkOutlet: linkName("outlet"), }; const b05 = shape(); - out.b05Length = ctl("pipe", "length").value; + out.b05Length = box("pipe").querySelector("output")?.textContent; // 보여 주는 칸(54-1) location.hash = "#/b06-section"; await until(() => location.hash.includes("b06"), 5000); await w(6000); @@ -187,7 +170,8 @@ const b06 = shape(); out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); if (!out.sameDrawing) out.diff = { b05, b06 }; - out.b06Length = ctl("pipe", "length").value; + out.b06Length = box("pipe").querySelector("output")?.textContent; + out.lengthInputs = box("pipe").querySelectorAll('[data-spec-item="pipe.length"] input').length; out.baseRows = Number(sessionStorage.getItem("__521_baseRows")); out.rows = await filledRows(); } diff --git a/resources/tester/ui_checks/54-1_배관_좌측패널.js b/resources/tester/ui_checks/54-1_배관_좌측패널.js new file mode 100644 index 000000000..5d303019e --- /dev/null +++ b/resources/tester/ui_checks/54-1_배관_좌측패널.js @@ -0,0 +1,134 @@ +/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) · + * 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 · + * 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N + * 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5) + * 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)" + * look → 지금 화면 배관 폼 수치(아무것도 안 바꿈) + * basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함) + * tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수 + * 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 · + * 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after · + * soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수 + */ +(async () => { + const MODE = window.__m541 || "look"; + // 고를 관(목록 이름) — 기본 「배관 1」 · 단이 서는 지형이 아니면 다른 관(window.__m541pipe) + const PIPE = window.__m541pipe || "배관 1"; + let station = ""; + 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 spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c, i = 0) => spec()?.querySelectorAll(`[data-spec-container="${c}"]`)[i]; + const items = (c) => + [...(box(c)?.querySelectorAll("[data-spec-item]") ?? [])].map( + (n) => n.dataset.specItem.split(".")[1], + ); + const set = async (c, item, v) => { + const el = box(c).querySelector(`[data-spec-item="${c}.${item}"]`).querySelector("select"); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const pickPipe = async () => { + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + l.textContent.trim().endsWith(PIPE), + ); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(500); + }; + const visible = (n) => n.offsetParent !== null && !n.closest("[hidden]"); + const tierRow = () => + box("outlet")?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const look = () => { + const lengthField = box("pipe")?.querySelector('[data-spec-item="pipe.length"]'); + const picks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter(visible); + return { + view: location.hash, + pickTexts: [...new Set(picks.map((b) => b.textContent))], + pickCount: picks.length, + gorugi: [...document.querySelectorAll("button")].filter( + (b) => visible(b) && b.textContent.trim() === "고르기", + ).length, + staleShown: [...document.querySelectorAll(".b05-structure__stale")].filter(visible).length, + staleLegend: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.includes("지난 정의 값"), + ).length, + lengthInputs: lengthField?.querySelectorAll("input").length, + lengthText: lengthField?.querySelector("output")?.textContent, + inletStructure: box("inlet")?.querySelector('[data-spec-item="inlet.structure"] select') + ?.value, + inletItems: items("inlet"), + soilAddButton: spec()?.querySelectorAll('[data-spec-add="soil_add"]').length, + soilRemove: spec()?.querySelectorAll('[data-spec-remove="soil_add"]').length, + chuteAddButton: spec()?.querySelectorAll('[data-spec-add="chute_add"]').length, + soilBoxes: [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])].map( + (b) => ({ + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map( + (n) => n.dataset.specItem.split(".")[1], + ), + link: !!b.querySelector('[data-b05-summary="pick"]'), + }), + ), + outletReadout: box("outlet")?.querySelectorAll(".b06-structure-panel__struct--readout") + .length, + outletText: box("outlet")?.textContent.includes("자동 자리"), + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + extraGroupShown: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + ).length, + }; + }; + await pickPipe(); + station = [...document.querySelectorAll(".b05-route__irregular-list li")] + .find((l) => l.textContent.trim().endsWith(PIPE)) + .textContent.trim() + .split(PIPE)[0]; + const out = { mode: MODE, pipe: PIPE, station }; + if (MODE === "look") Object.assign(out, look()); + if (MODE === "basin") { + const was = box("inlet").querySelector('[data-spec-item="inlet.structure"] select').value; + await set("inlet", "structure", "집수정"); + out.basinItems = items("inlet"); + await set("inlet", "structure", "기슭막이"); + out.soilItems = items("inlet"); + await set("inlet", "structure", was); + out.back = items("inlet"); + out.was = was; + } + if (MODE === "tier+" || MODE === "tier-") { + // 유출 벽 고르기(이미 골라져 단 수 줄이 서 있으면 그대로) + if (!tierRow()) { + // 판정면(관 위로 올린 것) — 4+5 카드의 유출 벽 + const wall = [...document.querySelectorAll(".b06-chart__culvert-revet-hit")].find( + (e) => + e.closest(".b06-cross-card")?.textContent.startsWith(station) && + e.querySelector("title")?.textContent.startsWith("유출"), + ); + out.wallFound = !!wall; + wall?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await until(() => tierRow(), 5000); + await w(500); + } + out.before = tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent; + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + (MODE === "tier+" + ? btns.find((b) => b.textContent === "+") + : btns.find((b) => b.textContent === "-") + )?.click(); + await w(1500); + Object.assign(out, look()); + } + return JSON.stringify(out); +})();