diff --git a/A00_Common/design/design_structure_ford.ts b/A00_Common/design/design_structure_ford.ts new file mode 100644 index 000000000..55bb897e0 --- /dev/null +++ b/A00_Common/design/design_structure_ford.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * design_structure_ford.ts + * 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로. + * + * 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조 + * (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음. + * 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */ +const guard = (side: "in" | "out"): SpecChoice[] => [ + { value: "없음", label: "없음", also: { [`wing_${side}`]: "없음" } }, + { value: "날개벽", label: "날개벽", also: { [`wing_${side}`]: "있음" } }, + { value: "흙막이", label: "흙막이", also: { [`wing_${side}`]: "없음" } }, +]; + +const guardContainer = (id: "inlet" | "outlet", title: string, link: string): SpecContainer => ({ + id, + title, + required: true, + link, + items: [ + { + id: "structure", + label: "구조", + kind: "select", + key: `${id}_type`, + choices: guard(id === "inlet" ? "in" : "out"), + }, + ], +}); + +export const FORD_SPEC: StructureSpec = { + typeId: "ford_bridge", + containers: [ + { + id: "ford", + title: "세월교", + required: true, + link: "gford", + items: [ + { + id: "diameter", + label: "관경", + kind: "select", + key: "pipe_diameter_mm", + asNumber: true, + choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })), + }, + { id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 }, + { id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 }, + { id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 }, + ], + }, + guardContainer("inlet", "유입 구조", "gfordin"), + guardContainer("outlet", "유출 구조", "gfordout"), + ], + // 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "pipe_kind", + "wing_in_height_m", + "wing_in_length_m", + "wing_in_angle_deg", + "wing_out_height_m", + "wing_out_length_m", + "wing_out_angle_deg", + ], +}; + +/** 구조를 날개벽 설치 칸(옛 저장 · B06 조정창이 고치는 칸)에 맞춤 — 있음 = 날개벽 · 없음 = 날개벽이면 없음 + * (흙막이는 그대로) · 설치 칸이 없으면 구조 그대로. */ +export function withLegacyGuards( + options: Record, +): Record { + const out = { ...options }; + for (const [type, wing] of [ + ["inlet_type", "wing_in"], + ["outlet_type", "wing_out"], + ]) { + if (out[wing] === "있음") out[type] = "날개벽"; + else if (out[wing] === "없음" && (out[type] ?? "날개벽") === "날개벽") out[type] = "없음"; + } + return out; +} diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts new file mode 100644 index 000000000..a9ec865a0 --- /dev/null +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -0,0 +1,27 @@ +/* ============================================================================= + * design_structure_ford_calc.ts + * 세월교 명세 계산 — 월류 폭 ↔ 월류 높이 연동(PLAN 52-4). + * + * 높이 = 담당 유역 설계유량을 그 월류 폭으로 넘기는 필요 수심(Manning 개수로 · `fordSection` 그대로). + * 근거: Manning 공식 `resources/knowledge/technical_info/01_임도/03_계산정보/통수단면.md:23~33` + * (KDS 44 40 00 2.7.1~2.7.2) · 조도계수 0.017 = 같은 파일 :59(콘크리트 수로 보통 · 울진 물넘이) · + * 경사 10 % = 실무 수리계산서 역산값(`config_frontend.FORD_SLOPE` · 지식DB 수치 없음). + * 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음. + * 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함. + * 설계유량이 없으면 null = 손 입력. + * ========================================================================== */ + +import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; + +/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ +export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { + const section = fordSection(designFlowM3s, widthM); + return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; +} + +/** 높이 빈 칸 안내. */ +export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { + const need = fordHeightM(designFlowM3s, widthM); + if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; + return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력"; +} diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index c72f84c0b..ffc019894 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -8,10 +8,11 @@ * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. * 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`. - * import 없음 — 시험이 이 파일만 옮겨 돌림. + * import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림. * ========================================================================== */ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; +import { FORD_SPEC } from "./design_structure_ford"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = { }; /** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ -export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; +export const STRUCTURE_SPECS: Readonly> = { + pipe: PIPE_SPEC, + ford_bridge: FORD_SPEC, +}; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index e7371d120..3b540ee46 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -53,6 +53,8 @@ import { } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +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 { hasLinks, @@ -250,45 +252,31 @@ export function createFacilityOptionsForm( root.className = "b05-drainage__facility"; root.hidden = true; - // ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ───────── - const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]); - const pipeDiameter = document.createElement("select"); - pipeDiameter.replaceChildren( - new Option("— 안 정함 —", ""), - ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), - ); - const pipeRow = grid( - labeled("관종", pipeMaterial, "pipe_kind"), - labeled("관경", pipeDiameter, "pipe_diameter_mm"), - ); - // ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ── let linkSource: SpecLinks | null = null; let chainage: number | null = null; - const pipeForm = createSpecForm( - { - get: () => linkSource?.get() ?? {}, - pick: (key) => linkSource?.pick(key), - clear: (key) => linkSource?.clear(key), - set: (key, item) => linkSource?.set(key, item), + const specLinks: SpecLinks = { + get: () => linkSource?.get() ?? {}, + pick: (key) => linkSource?.pick(key), + clear: (key) => linkSource?.clear(key), + set: (key, item) => linkSource?.set(key, item), + }; + const pipeForm = createSpecForm(specLinks, { + auto: (_c, item) => { + if (item.id !== "length") return ""; + const length = pipeLengthM(chainage); + return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); }, - { - auto: (_c, item) => { - if (item.id !== "length") return ""; - const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); - }, - hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; - return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; - }, - onChange: (c, item) => { - if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) - inletStructureHandler?.(inletStructureOf()); - emit(); - }, + hint: (c, item, state) => { + const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, - ); + onChange: (c, item) => { + if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) + inletStructureHandler?.(inletStructureOf()); + emit(); + }, + }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) const inletSlot = pipeForm.slot("inlet"); const outletSlot = pipeForm.slot("outlet"); @@ -301,6 +289,26 @@ export function createFacilityOptionsForm( : "auto"; }; + // ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ── + // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) + let fordFlow: number | null = null; + const fordNeed = (): number | null => + fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); + const fordForm = createSpecForm(specLinks, { + hint: (c, item, state) => + c.id === "ford" && item.id === "height" + ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) + : "", + onChange: (c, item) => { + const need = c.id === "ford" ? fordNeed() : null; + const height = fordForm.state().ford?.[0]?.height ?? ""; + const low = height !== "" && !(Number(height) >= (need ?? 0)); + if (need !== null && (item.id === "width" || (item.id === "height" && low))) + fordForm.setValue("ford", "height", need.toFixed(2)); + emit(); + }, + }); + // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). @@ -370,6 +378,8 @@ export function createFacilityOptionsForm( ); const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); + const wingInHome = wingInFields.slot.parentElement!; + const wingOutHome = wingOutFields.slot.parentElement!; const hiddenOptions = createHiddenOptions(root); @@ -399,8 +409,8 @@ export function createFacilityOptionsForm( // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( saveNote, - pipeRow, pipeForm.root, + fordForm.root, ford.widthRow, ford.slopeRow, ford.countRow, @@ -421,15 +431,18 @@ export function createFacilityOptionsForm( if (current === null) return; const isPipe = current === "pipe"; const isBox = current === "box_culvert"; - pipeRow.hidden = current !== "ford_bridge"; + const isBridge = current === "ford_bridge"; pipeForm.root.hidden = !isPipe; + fordForm.root.hidden = !isBridge; boxWrap.hidden = !isBox; - // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). - const hasWing = isBox || current === "ford_bridge"; + // 날개벽 칸 = BOX암거 · 세월교는 유입 · 유출 구조 컨테이너(52-4) — B06 조정 행 자리만 그 컨테이너 끝으로 + const hasWing = isBox; wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; - const isFord = current === "ford_pavement" || current === "ford_bridge"; - ford.countRow.hidden = current !== "ford_bridge"; + (isBridge ? fordForm.slot("inlet") : wingInHome).append(wingInFields.slot); + (isBridge ? fordForm.slot("outlet") : wingOutHome).append(wingOutFields.slot); + const isFord = current === "ford_pavement"; + ford.countRow.hidden = true; ford.widthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. ford.slopeRow.hidden = current !== "ford_pavement"; @@ -462,8 +475,6 @@ export function createFacilityOptionsForm( revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); [ - pipeMaterial, - pipeDiameter, boxSize, boxWidth, boxHeight, @@ -500,8 +511,12 @@ export function createFacilityOptionsForm( bindLinks(links) { linkSource = links; pipeForm.paintLinks(); + fordForm.paintLinks(); + }, + paintLinks: () => { + pipeForm.paintLinks(); + fordForm.paintLinks(); }, - paintLinks: () => pipeForm.paintLinks(), setChainage(chainageM) { chainage = chainageM; if (current === "pipe") pipeForm.refresh(); @@ -516,6 +531,7 @@ export function createFacilityOptionsForm( current = facility; hiddenOptions.remember(options); ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; if (facility === null) { syncVisibility(); return; @@ -523,12 +539,11 @@ export function createFacilityOptionsForm( const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). - pipeMaterial.value = text("pipe_kind"); - pipeDiameter.value = text("pipe_diameter_mm"); - if (facility === "pipe") { - const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); - } + if (facility === "ford_bridge") + fordForm.write(FORD_SPEC, withLegacyGuards(options), definition?.spec_names ?? {}, groups); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 「안 정함」. const savedWidth = Number.parseFloat(text("body_width_m")); @@ -549,7 +564,7 @@ export function createFacilityOptionsForm( boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); - if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); + if (facility === "ford_pavement") ford.write(facility, options); revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -558,15 +573,13 @@ export function createFacilityOptionsForm( }, setDesignFlow(designFlow) { ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; + if (current === "ford_bridge") fordForm.refresh(); // 높이 안내 syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). - const putPipe = (): void => { - if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); - if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; - }; if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) } else if (current === "box_culvert") { @@ -578,10 +591,7 @@ export function createFacilityOptionsForm( } else if (current === "ford_pavement") { ford.read(current, options); } else if (current === "ford_bridge") { - putPipe(); - ford.read(current, options); - wingInFields.read(options); - wingOutFields.read(options); + Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) } else if (current === "revetment") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 4e6afd657..51b56df66 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -42,14 +42,16 @@ export const FORM_OPTION_KEYS: Readonly> = { ], box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + // 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임 ford_bridge: [ - "pipe_kind", "pipe_diameter_mm", "pipe_count", "ford_width_m", "ford_height_m", - ...wingKeys("in"), - ...wingKeys("out"), + "inlet_type", + "outlet_type", + "wing_in", + "wing_out", ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 26899fdc1..d16428ac1 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -26,6 +26,8 @@ import { } from "./M02_SO_Def_Model"; import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; +import { buildSpecBlock } from "./M02_SO_Def_Spec"; +import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -182,6 +184,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { } const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); + // 명세가 있는 종류(PLAN 52) = 이름만 고침 — 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 숨김 + const spec = STRUCTURE_SPECS[type.type_id]; detail.replaceChildren( el("div", { className: "m02-sod__kindhead", @@ -196,16 +200,20 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { edit, attr: "data-sod-kind-off", }), - checkBox( - to("BaseStationShow"), - baseShown(d, type.type_id), - (v) => { - if (v) delete extra().기준측점; - else extra().기준측점 = false; - changed(); - }, - { edit, attr: "data-sod-base" }, - ), + ...(spec + ? [] + : [ + checkBox( + to("BaseStationShow"), + baseShown(d, type.type_id), + (v) => { + if (v) delete extra().기준측점; + else extra().기준측점 = false; + changed(); + }, + { edit, attr: "data-sod-base" }, + ), + ]), ...(d.종류[type.type_id]?.동작 ?? []).map((a) => { const tag = createTag(a, "neutral"); tag.title = d.동작[a] ?? ""; @@ -213,10 +221,14 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { }), ], }), - buildOptionsBlock({ def: d, type, edit, open, changed }), - buildLayoutBlock({ def: d, type, edit, changed }), - buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), - buildRulesBlock({ def: d, type, edit, changed }), + ...(spec + ? [buildSpecBlock({ def: d, type, spec, edit, changed })] + : [ + buildOptionsBlock({ def: d, type, edit, open, changed }), + buildLayoutBlock({ def: d, type, edit, changed }), + buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), + buildRulesBlock({ def: d, type, edit, changed }), + ]), ); }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts index d45d9511b..2cb6355bb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -57,7 +57,8 @@ export interface StructDef { 구조물군: Record; 동작: Record; types: TypeDef[]; - 종류: Record; + /** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */ + 종류: Record }>; 표시: Record>; 그룹: Record; 배치: Record>; @@ -257,6 +258,10 @@ export function problems(def: StructDef): string[] { if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); } const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); + const spec = Object.entries(def.종류[t.type_id]?.이름 ?? {}) + .filter(([, v]) => !v.trim() || v.length > NAME_MAX) + .map(([k]) => k); + if (spec.length) out.push(`${t.name} 명세 이름: ${spec.join(", ")}`); if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts new file mode 100644 index 000000000..63157ee50 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * M02_SO_Def_Spec.ts + * 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴 + * 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 · + * 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음. + * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. + * ========================================================================== */ + +import { createTag, el } from "@ui/ui_template_elements"; +import { createSpecForm } from "../../A00_Common/design/design_structure_form"; +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; +import { setSpecName, specNameRows, type SpecNameRow } from "./M02_SO_Def_Spec_Model"; +import { head, textBox } from "./M02_SO_Def_Ui"; +import { to, type OptionsTextKey } from "./M02_SO_Text"; + +const TAG: Record, OptionsTextKey> = { + required: "SpecRequired", + optional: "SpecOptional", + select: "FormSelect", + number: "FormNumber", + auto: "FormAuto", +}; + +export function buildSpecBlock(opt: { + def: StructDef; + type: TypeDef; + spec: StructureSpec; + edit: boolean; + changed: () => void; +}): HTMLElement { + const { def, type, spec, edit, changed } = opt; + const rows = specNameRows(def, spec).map((row) => + el("div", { + className: `m02-sod__specrow is-level-${row.level}`, + attrs: { "data-sod-spec": row.nameKey }, + children: [ + textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, { + edit, + attr: "data-sod-spec-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: row.key }), + ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), + ], + }), + ); + // 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫) + const preview = createSpecForm(null); + const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); + preview.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + preview.root.inert = true; + return el("div", { + className: "m02-sod__block", + attrs: { "data-sod-spec-block": "" }, + children: [ + ...head(to("SpecHead"), to("SpecNote")), + ...rows, + ...head(to("SpecPreview")), + el("div", { + className: "m02-sod__specpreview", + attrs: { "data-sod-spec-preview": "" }, + children: [preview.root], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts new file mode 100644 index 000000000..bd6bc693b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * M02_SO_Def_Spec_Model.ts + * 구조물 명세(PLAN 52 · `design_structure_spec`) 이름 고치기(52-11)의 자료 손질 — 화면 그리기 없음. + * + * · 명세 = 구조 정본(키 · 컨테이너 · 필수/선택 · 꼴) — 여기서는 **이름만** 고침 + * · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`) + * · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기 + * (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀 + * import 는 꼴만 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import type { StructDef } from "./M02_SO_Def_Model"; + +export interface SpecNameRow { + /** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */ + level: 0 | 1 | 2; + /** 이름 표 키 */ + nameKey: string; + /** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */ + key: string; + /** 지금 이름 */ + label: string; + /** 읽기만 표시 — 필수/선택 · 꼴 */ + tag: "required" | "optional" | "select" | "number" | "auto" | null; + /** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */ + group: string | null; +} + +/** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */ +export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] { + const names = def.종류[spec.typeId]?.이름 ?? {}; + const groups = def.그룹[spec.typeId] ?? []; + const rows: SpecNameRow[] = []; + const seen = new Set(); + const push = (row: SpecNameRow): void => { + if (seen.has(row.nameKey)) return; + seen.add(row.nameKey); + rows.push(row); + }; + for (const c of spec.containers) { + const group = + c.required && names[c.id] === undefined ? groups.find((g) => g.id === c.link) : undefined; + push({ + level: 0, + nameKey: c.id, + key: c.id, + label: names[c.id] ?? group?.이름 ?? c.title, + tag: c.required ? "required" : "optional", + group: group ? group.id : null, + }); + for (const item of c.items) { + const itemKey = `${c.id}.${item.id}`; + push({ + level: 1, + nameKey: itemKey, + key: item.id, + label: names[itemKey] ?? item.label, + tag: item.kind, + group: null, + }); + const choices = [ + ...(item.choices ?? []), + ...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []), + ]; + for (const ch of choices) { + const choiceKey = `${itemKey}=${ch.value}`; + push({ + level: 2, + nameKey: choiceKey, + key: ch.value, + label: names[choiceKey] ?? ch.label, + tag: null, + group: null, + }); + } + } + } + return rows; +} + +/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 나머지는 이름 표. */ +export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void { + const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined; + if (group) { + group.이름 = value; + return; + } + const kind = (def.종류[typeId] ??= {}); + (kind.이름 ??= {})[row.nameKey] = value; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index a8570c86a..492eb8c4f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -332,3 +332,28 @@ margin: 0; color: var(--color-danger); } + +/* 구조물 명세 이름 고치기(PLAN 52-11) — 컨테이너 › 항목 › 값 들여쓰기 */ +.m02-sod__specrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sod__specrow.is-level-0 { + margin-top: var(--spacing-8); + font-weight: 600; +} + +.m02-sod__specrow.is-level-1 { + padding-left: 1.5rem; +} + +.m02-sod__specrow.is-level-2 { + padding-left: 3rem; +} + +.m02-sod__specpreview { + max-width: 28rem; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 90037edd8..3235e7762 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -45,6 +45,15 @@ const TEXT = { FormNumber: ["숫자", "Number"], FormText: ["글", "Text"], FormBool: ["예/아니오", "Yes/No"], + FormAuto: ["자동", "Auto"], + SpecHead: ["구조물 명세 이름", "Structure spec names"], + SpecNote: [ + "컨테이너 제목 › 항목 이름 › 값 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 구조는 구조물 명세가 정함 · [저장]하면 B05 · B06 이 이 이름으로 그림", + "Rename containers › items › values only — keys, required/optional, form and structure come from the spec", + ], + SpecRequired: ["필수", "Required"], + SpecOptional: ["선택", "Optional"], + SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"], Yes: ["예", "Yes"], No: ["아니오", "No"], Unit: ["단위", "Unit"], diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index a62bd0a6e..c2dbb92a6 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3321,7 +3321,25 @@ "계곡통과시설", "월류단면", "날개벽" - ] + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "ford.count": "수량 (련)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } }, "revetment": { "동작": [ diff --git a/resources/tester/helper_52_4_ford.cjs b/resources/tester/helper_52_4_ford.cjs new file mode 100644 index 000000000..b11d13b4c --- /dev/null +++ b/resources/tester/helper_52_4_ford.cjs @@ -0,0 +1,86 @@ +/* 세월교 명세(표 04) · 월류 폭 ↔ 높이 계산 단위검증 헬퍼(PLAN 52-4) — `design_structure_spec.ts` · + * `design_structure_ford.ts` · `design_structure_ford_calc.ts`(+ `fordSection` 이 든 B05 칸 조각)를 그 자리에서 + * 트랜스파일해 Node 로 돌림. 결과 = JSON 한 벌. 인자 `names` 면 세월교 이름 표(정의 `종류.ford_bridge.이름`)만 냄. + */ +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(); +/** TS 한 파일을 CommonJS 로 — 상대 import 는 그 파일 자리 기준 · `@config/` 는 config 폴더 · .json 은 그대로. */ +const load = (file) => { + if (cache.has(file)) return cache.get(file); + if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8")); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + const base = m.startsWith("@config/") + ? path.join(ROOT, "config", m.slice(8)) + : path.join(path.dirname(file), m); + return load(base.endsWith(".json") ? base : `${base}.ts`); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + cache.set(file, box.exports); + return box.exports; +}; +const D = (f) => path.join(ROOT, "A00_Common", "design", f); +const s = load(D("design_structure_spec.ts")); +const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts")); +const calc = load(D("design_structure_ford_calc.ts")); + +if (process.argv[2] === "names") { + process.stdout.write(JSON.stringify(s.nameTable(FORD_SPEC))); + process.exit(0); +} + +const old = { + pipe_kind: "파형강관", + pipe_diameter_mm: 1000, + pipe_count: 2, + ford_width_m: 10, + ford_height_m: 0.3, + wing_in: "있음", + wing_in_height_m: 1, + wing_out: "없음", +}; +const legacy = withLegacyGuards(old); +const state = s.readState(FORD_SPEC, legacy); +const back = s.writeOptions(FORD_SPEC, state); +const soil = JSON.parse(JSON.stringify(state)); +soil.inlet[0].structure = "흙막이"; +soil.outlet[0].structure = "날개벽"; +const soilOut = s.writeOptions(FORD_SPEC, soil); + +const flows = [0.5, 2.54, 8]; +const widths = [3, 5, 10, 20]; +const table = flows.flatMap((q) => widths.map((b) => [q, b, calc.fordHeightM(q, b)])); + +const out = { + containers: FORD_SPEC.containers.map((c) => [c.id, c.title, c.required]), + items: FORD_SPEC.containers.map((c) => c.items.map((i) => [i.id, i.kind, i.key ?? null])), + choices: FORD_SPEC.containers.map((c) => + c.items.filter((i) => i.kind === "select").map((i) => s.choicesOf(i, {}).map((ch) => ch.label)), + ), + legacy: { inlet: legacy.inlet_type, outlet: legacy.outlet_type }, + keepSoil: withLegacyGuards({ inlet_type: "흙막이", wing_in: "없음" }).inlet_type, + b06Off: withLegacyGuards({ inlet_type: "날개벽", wing_in: "없음" }).inlet_type, + b06On: withLegacyGuards({ inlet_type: "없음", wing_in: "있음" }).inlet_type, + state, + back, + soilOut, + links: FORD_SPEC.containers.map((c) => s.linkOf(c, 1)), + keys: s.specKeys(FORD_SPEC), + retired: FORD_SPEC.retired, + table, + none: [calc.fordHeightM(null, 10), calc.fordHeightM(2.54, 0)], + hints: [ + calc.fordHeightHint(2.54, 10), + calc.fordHeightHint(2.54, 0), + calc.fordHeightHint(null, 10), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 0ff82723b..168721216 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -13,7 +13,8 @@ const load = (file, deps = {}) => { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m)); + const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); // 같은 폴더 명세 + new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? near(m)); return box.exports; }; const s = load("design_structure_spec.ts"); diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs new file mode 100644 index 000000000..ede10f95b --- /dev/null +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -0,0 +1,64 @@ +/* M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). + * 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (...parts) => { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, ...parts), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +}; +const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts"); +const { PIPE_SPEC } = load("A00_Common", "design", "design_structure_pipe.ts"); +const real = () => + JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), + ); + +const out = {}; +const def = real(); +const rows = m.specNameRows(def, PIPE_SPEC); +out.levels = [0, 1, 2].map((lv) => rows.filter((r) => r.level === lv).length); +out.unique = new Set(rows.map((r) => r.nameKey)).size === rows.length; +out.pipeTitle = rows.find((r) => r.nameKey === "pipe"); +out.optional = rows.find((r) => r.nameKey === "soil_add"); +out.basinForm = rows.find((r) => r.nameKey === "inlet.form=I"); + +// 이름 고침 — 그룹 제목 · 항목 · 값 · 선택 컨테이너 +const work = real(); +const pick = (k) => m.specNameRows(work, PIPE_SPEC).find((r) => r.nameKey === k); +m.setSpecName(work, "pipe", pick("pipe"), "관 본체"); +m.setSpecName(work, "pipe", pick("pipe.diameter"), "관 지름"); +m.setSpecName(work, "pipe", pick("pipe.diameter=800"), "Ø800"); +m.setSpecName(work, "pipe", pick("soil_add"), "흙막이 더"); +out.groupName = work.그룹.pipe.find((g) => g.id === "grv6lny").이름; +out.names = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map( + (k) => work.종류.pipe.이름[k] ?? null, +); +out.reread = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map((k) => pick(k).label); +// 구조는 그대로 — types · 동작 · 다른 종류 · 이름 표 키 수(새 키 없음) +const before = real(); +out.sameTypes = JSON.stringify(work.types) === JSON.stringify(before.types); +out.sameKeys = + Object.keys(work.종류.pipe.이름).length === Object.keys(before.종류.pipe.이름).length; + +// 이름 표가 빈 종류 — 명세 글로 읽고 고치면 이름 표가 생김 +const bare = real(); +delete bare.종류.pipe.이름; +const bareRows = m.specNameRows(bare, PIPE_SPEC); +out.bareLabel = bareRows.find((r) => r.nameKey === "inlet.structure=기슭막이").label; +m.setSpecName( + bare, + "pipe", + bareRows.find((r) => r.nameKey === "inlet"), + "유입", +); +out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py new file mode 100644 index 000000000..90a973a9b --- /dev/null +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 세월교(표 04) 명세 + 월류 폭 ↔ 높이 연동 — PLAN 52-4. + +순수 규칙은 Node 헬퍼(`helper_52_4_ford.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-4_세월교_컨테이너.js`. +높이 거울 = 이 파일이 Manning(통수단면.md §2 · n 0.017 · S 0.10)을 따로 풀어 같은 값인지 봄. +""" + +import json +import math +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +N, S = 0.017, 0.10 + + +def _node(*args): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_4_ford.cjs"), *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(): + return _node() + + +def test_컨테이너_셋_모두_필수(out): + assert out["containers"] == [ + ["ford", "세월교", True], + ["inlet", "유입 구조", True], + ["outlet", "유출 구조", True], + ] + assert out["items"][0] == [ + ["diameter", "select", "pipe_diameter_mm"], + ["width", "number", "ford_width_m"], + ["height", "number", "ford_height_m"], + ["count", "number", "pipe_count"], + ] + assert out["choices"] == [ + [["800", "1000", "1200", "1500"]], + [["없음", "날개벽", "흙막이"]], + [["없음", "날개벽", "흙막이"]], + ] + + +def test_옛_날개벽_칸으로_구조를_세우고_같은_키로_씀(out): + assert out["legacy"] == {"inlet": "날개벽", "outlet": "없음"} + assert out["keepSoil"] == "흙막이" # 흙막이는 설치 칸 없음이어도 그대로 + assert out["b06Off"] == "없음" and out["b06On"] == "날개벽" # B06 조정창 설치 칸을 따름 + back = out["back"] + assert back["pipe_diameter_mm"] == 1000 and back["pipe_count"] == 2 + assert back["ford_width_m"] == 10 and back["ford_height_m"] == 0.3 + assert back["wing_in"] == "있음" and back["wing_out"] == "없음" + # 표 04 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) + assert "pipe_kind" not in back and "wing_in_height_m" not in back + assert "pipe_kind" in out["retired"] + + +def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out): + s = out["soilOut"] + assert s["inlet_type"] == "흙막이" and s["wing_in"] == "없음" + assert s["outlet_type"] == "날개벽" and s["wing_out"] == "있음" + + +def test_잇기_키_컨테이너마다(out): + assert out["links"] == ["gford", "gfordin", "gfordout"] + + +def _manning_depth(q, b): + """넓은 직사각 수로 Manning 을 h 에 대해 이분법으로 풂 — 헬퍼와 다른 풀이.""" + + def cap(h): + return b * h * (1 / N) * ((b * h) / (b + 2 * h)) ** (2 / 3) * math.sqrt(S) + + lo, hi = 0.0, 5.0 + for _ in range(100): + mid = (lo + hi) / 2 + lo, hi = (mid, hi) if cap(mid) < q else (lo, mid) + return hi + + +def test_월류_높이는_폭과_연동_필요_수심_올림(out): + for q, b, h in out["table"]: + need = _manning_depth(q, b) + assert h == pytest.approx(math.ceil(need * 100 - 1e-9) / 100), (q, b) + # 폭이 넓을수록 높이가 낮아짐 + row = [h for q, b, h in out["table"] if q == 2.54] + assert row == sorted(row, reverse=True) and len(set(row)) == len(row) + assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력 + assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"] + + +def test_정의_파일에_세월교_이름_표가_올라_있음(): + names = _node("names") + with open( + os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" + ) as f: + doc = json.load(f) + assert doc["종류"]["ford_bridge"]["이름"] == names diff --git a/resources/tester/test_m02_so_def_spec.py b/resources/tester/test_m02_so_def_spec.py new file mode 100644 index 000000000..3677816ff --- /dev/null +++ b/resources/tester/test_m02_so_def_spec.py @@ -0,0 +1,58 @@ +"""M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증. + +Node 헬퍼(`helper_m02_so_def_spec.cjs`)가 TS 를 트랜스파일해 진짜 정의 사본(메모리)으로 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배관 명세 나무: 컨테이너 5 · 항목 · 값 줄 · 이름 키 겹침 없음 · 필수/선택 · 꼴 표시. + - 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀. + - 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음. + - 이름 표가 빈 종류는 명세 글로 읽음. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_spec.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배관_나무(r) -> None: + assert r["levels"][0] == 5 + assert r["levels"][1] > 5 and r["levels"][2] > 10 + assert r["unique"] is True + assert r["pipeTitle"]["tag"] == "required" and r["pipeTitle"]["group"] == "grv6lny" + assert r["optional"]["tag"] == "optional" and r["optional"]["group"] is None + assert r["basinForm"]["label"] == "I형" and r["basinForm"]["key"] == "I" + + +def test_이름_고침_자리(r) -> None: + assert r["groupName"] == "관 본체" + assert r["names"] == [None, "관 지름", "Ø800", "흙막이 더"] + assert r["reread"] == ["관 본체", "관 지름", "Ø800", "흙막이 더"] + + +def test_구조는_그대로(r) -> None: + assert r["sameTypes"] is True and r["sameKeys"] is True + + +def test_이름_표_없는_종류(r) -> None: + assert r["bareLabel"] == "흙막이" + assert r["bareGroup"] == "유입" diff --git a/resources/tester/ui_checks/52-11_정의_이름고치기.js b/resources/tester/ui_checks/52-11_정의_이름고치기.js new file mode 100644 index 000000000..6d4679afe --- /dev/null +++ b/resources/tester/ui_checks/52-11_정의_이름고치기.js @@ -0,0 +1,155 @@ +/* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여 + * M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자: + * orca eval --expression "window.__m5211='m02'; $(cat 이 파일)" + * B05 종단설계(#/b05-profile) · 목록에 「배관 1」: + * orca eval --expression "window.__m5211='b05'; $(cat 이 파일)" + * 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 · + * B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림 + * 기대 수치(m02): + * - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류는 옛 편집기 + * - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님) + * - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐 + * - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로 + * 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true + */ +(async () => { + const MODE = window.__m5211 || "m02"; + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 10000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const put = (box, value) => { + box.value = value; + box.dispatchEvent(new Event("input", { bubbles: true })); + box.dispatchEvent(new Event("change", { bubbles: true })); + }; + const NEW = { pipe: "관 본체X", "pipe.diameter": "관 지름X", "pipe.diameter=800": "Ø800X" }; + const out = { mode: MODE }; + + if (MODE === "m02") { + const getDef = async () => + (await fetch("/api/m02/structure-definition", { credentials: "include" })).json(); + const first = await getDef(); + out.판 = first.판; + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ error: "정의 줄 없음" }); + row.click(); + if (!(await until(() => $$("[data-sod-kind]").length))) + return JSON.stringify({ error: "종류 목록 없음" }); + // 명세 없는 종류 — 옛 편집기 그대로 + $('[data-sod-kind="retaining_wall"]').click(); + await W(200); + out.other = { base: !!$("[data-sod-base]"), spec: !!$("[data-sod-spec-block]") }; + // 배관 — 명세 이름 나무 + $('[data-sod-kind="pipe"]').click(); + await W(200); + const block = $("[data-sod-spec-block]"); + const detail = $("[data-sod-detail]"); + const rows = $$("[data-sod-spec]", block); + out.spec = { + rows: rows.length, + containers: $$(".is-level-0", block).length, + inputs: $$("input, select, textarea", block).filter( + (n) => !n.closest("[data-sod-spec-preview]"), + ).length, + nameInputs: $$("[data-sod-spec-name]", block).length, + keysReadOnly: rows.every((r) => r.querySelector("small.m02-opts__key")), + }; + out.old = { + base: !!$("[data-sod-base]", detail), + blocks: detail.children.length, // 머리 칸 + 명세 칸 = 2 + }; + const save = $("[data-sod-save]"); + out.saveBefore = save.disabled; + const box = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`); + const orig = Object.fromEntries(Object.keys(NEW).map((k) => [k, box(k).value])); + for (const [k, v] of Object.entries(NEW)) { + put(box(k), v); + await W(150); + } + const preview = $("[data-sod-spec-preview]"); + const pipeBox = $('[data-spec-container="pipe"]', preview); + out.preview = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + out.saveAfterEdit = $("[data-sod-save]").disabled; + // 빈 이름 → 저장 막힘 + put(box("pipe.diameter"), ""); + await W(150); + out.blank = { + issue: $("[data-sod-issues]").textContent.includes("명세 이름"), + save: $("[data-sod-save]").disabled, + }; + // 되돌림 + for (const [k, v] of Object.entries(orig)) { + put(box(k), v); + await W(150); + } + out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v); + out.판After = (await getDef()).판; + out.ok = + out.other.base && + !out.other.spec && + out.spec.containers === 5 && + out.spec.inputs === out.spec.rows && + out.spec.nameInputs === out.spec.rows && + out.spec.keysReadOnly && + !out.old.base && + out.old.blocks === 2 && + out.preview.title && + out.preview.item && + out.preview.choice && + !out.saveAfterEdit && + out.blank.issue && + out.blank.save && + out.restored && + out.판After === out.판; + } else { + const sec = $(".b05-structure-section"); + if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" }); + const url = performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts")) + .pop(); + const api = await import(url); + const pipe = (await api.fetchStructureTypes(true)).find((t) => t.type_id === "pipe"); + pipe.spec_names = { ...(pipe.spec_names ?? {}), ...NEW }; + await until(() => $$(".b05-route__irregular-list li").length); + const li = $$(".b05-route__irregular-list li").find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + const spec = () => $(".b05-structure__spec", sec); + (li.querySelector("button") ?? li).click(); + await W(800); + if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click(); + await until(() => spec() && spec().offsetParent !== null); + const pipeBox = $('[data-spec-container="pipe"]', spec()); + out.named = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + out.ok = Object.values(out.named).every(Boolean); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-4_세월교_컨테이너.js b/resources/tester/ui_checks/52-4_세월교_컨테이너.js new file mode 100644 index 000000000..c5c9143e0 --- /dev/null +++ b/resources/tester/ui_checks/52-4_세월교_컨테이너.js @@ -0,0 +1,181 @@ +/* 52-4 — B05 · B06 좌측 「구조물 배치」 세월교 = 사용자 구조물 표 04 명세(컨테이너 셋) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 목록에 「세월교 1」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m524 = "단계";` 를 붙여 + * orca eval --expression "window.__m524='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠) + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m524='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m524='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 3(세월교 · 유입 구조 · 유출 구조 · 모두 필수) · 관경 800/1000/1200/1500 · + * 구조 없음/날개벽/흙막이 · 옛 관종 · 날개벽 칸 안 보임 · 폭 8 → 높이 = 필요 수심(설계유량 있으면) · + * 낮게 적으면 필요 수심으로 되돌림 · 수량 3 · 잇기 세월교 · 유입 지정 · 유출 지정 뒤 풂 + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m524 || "edit"; + const NAME = /세월교 1$/; + 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 projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec().querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, item) => + box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text); + const pickFord = async () => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && NAME.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem, + n.querySelector("select, input").value, + ]), + link: b.querySelector(".b05-structure__summary-name")?.textContent, + })); + const assigned = (c) => + box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); + const pickLink = async (c) => { + box(c).querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const visibleKeys = () => + [...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")] + .filter((n) => n.offsetParent !== null) + .map((n) => n.dataset.optionKeys); + const out = { mode: MODE }; + + if (MODE === "edit") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + sessionStorage.setItem("__524_original", JSON.stringify(before.pipe_points)); + await pickFord(); + const s0 = shape(); + out.containers = s0.map((b) => [b.c, b.legend]); + out.oldFieldsVisible = visibleKeys(); // 옛 관종 · 월류 · 날개벽 칸 — 빈 목록이어야 + out.diameters = labels("ford", "diameter"); + out.inletChoices = labels("inlet", "structure"); + out.outletChoices = labels("outlet", "structure"); + out.start = s0.map((b) => b.items); + await set("ford", "diameter", "1200"); + await set("ford", "width", "8"); + out.heightAfterWidth8 = ctl("ford", "height").value; + out.heightHint = ctl("ford", "height").placeholder; + await set("ford", "width", "4"); + out.heightAfterWidth4 = ctl("ford", "height").value; + await set("ford", "height", "0.01"); + out.heightLowBounced = ctl("ford", "height").value; + const raised = (Number(out.heightAfterWidth4 || 0) + 0.2).toFixed(2); + await set("ford", "height", raised); + out.heightRaisedKept = ctl("ford", "height").value === raised; + await set("ford", "count", "3"); + await set("inlet", "structure", "흙막이"); + await set("outlet", "structure", "날개벽"); + await pickLink("ford"); + await pickLink("inlet"); + await pickLink("outlet"); + out.linksPicked = ["ford", "inlet", "outlet"].map(assigned); + box("outlet").querySelector('[data-b05-summary="clear"]').click(); + await w(300); + out.linkOutletCleared = !assigned("outlet"); + out.edited = shape().map((b) => b.items); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const after = await fetchApi.fetchDetailPipePoints(projectId); + const p = after.pipe_points.find((x) => x.chainage_m > 170 && x.chainage_m < 176); // 세월교 1 = 8+13 + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) }; + } + + if (MODE === "verify") { + await pickFord(); + const b05 = shape(); + out.reopened = b05.map((b) => b.items); + out.links = ["ford", "inlet", "outlet"].map(assigned); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pickFord(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + out.b06WingSlots = ["inlet", "outlet"].map( + (c) => box(c).querySelector(".b05-structure__adjust-slot")?.childElementCount ?? -1, + ); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__524_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const keys = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const points = original.map((p) => + Object.fromEntries( + keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + await fetchApi.saveDetailPipePoints(projectId, points); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:") || k === "__524_original") + sessionStorage.removeItem(k); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const pick = (list) => + JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}])); + out.restored = pick(now.pipe_points) === pick(original); + } + return JSON.stringify(out); +})();