diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index d9ccc19d2..8f3f729bf 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -32,7 +32,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { prefix: "ford_chute", link: "gfordpavechute", max: 1, - // 구조 · 두께(cm · 새 상자 30) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) + // 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) items: [ { id: "structure", @@ -42,8 +42,9 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { { value: "도수로", label: "도수로" }, { value: "돌붙임", label: "돌붙임" }, ], + default: "도수로", }, - { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" }, + { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, ], }, ], diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 9a7ee8c20..de75abeb6 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -4,7 +4,8 @@ * * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] · * `max` 에 차면 [추가] 숨김 - * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) · + * 체크(공용 체크박스 · "true" / "false") * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 @@ -40,6 +41,7 @@ import { import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; import { followPipeHeights } from "../../common_util/common_util_culvert_sets"; +import { createCheckbox } from "../../ui_template/ui_template_checkbox"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { @@ -142,6 +144,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = tag ? "" : now; return select; } + if (item.kind === "bool") { + // 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false" + const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" }); + box.input.value = String(box.input.checked); + box.input.addEventListener("change", () => (box.input.value = String(box.input.checked))); + return box.root; + } if (item.kind === "text") { const input = document.createElement("input"); input.type = "text"; diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index ef37fa087..c5d18b956 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -120,7 +120,7 @@ export const SOIL_ADD: SpecContainer = { items: guardItems(), }; -/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 붙임 형식 · 면적(98-3) · 물넘이 포장(`ford_chute`)도 같은 칸. */ +/** 도수로/돌붙임 칸 — 구조(기본 도수로) · 형태(기본 메붙임) · 두께(cm · 기본 45) · 면적(㎡ · 기본 30 · 98-3) · 물넘이 포장(`ford_chute`)도 같은 칸(173-5). */ export const CHUTE_ITEMS: SpecItem[] = [ { id: "structure", @@ -130,19 +130,20 @@ export const CHUTE_ITEMS: SpecItem[] = [ { value: "도수로", label: "도수로" }, { value: "돌붙임", label: "돌붙임" }, ], + default: "도수로", }, - { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" }, - // 붙임 형식 · 면적(98-3) — 빈칸이면 B08 구조물 집계표에 안 셈(설계자가 넣음) { id: "bond", - label: "붙임 형식", + label: "형태", kind: "select", choices: [ { value: "찰붙임", label: "찰붙임" }, { value: "메붙임", label: "메붙임" }, ], + default: "메붙임", }, - { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1 }, + { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, + { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" }, ]; /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 3c81eca63..3aa987a31 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -70,6 +70,14 @@ export const PIPE_SPEC: StructureSpec = { [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), + // 집수정만 · 빈 값 = 참(키 없는 옛 관도 동물이동형 · 173-7) · 저장만(B07 · B08 안 읽음) + { + id: "animal", + label: "동물이동형", + kind: "bool", + default: "true", + by: { item: "structure", map: { [BASIN]: { key: "inlet_basin_animal" } } }, + }, ], }, { diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 3ba11d1d1..5c80408e8 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -32,7 +32,8 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - kind: "select" | "number" | "text" | "auto"; + /** bool = 체크박스 · 저장 글 "true" / "false" · 빈 값 = `default`(173-7). */ + kind: "select" | "number" | "text" | "auto" | "bool"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 775dcc8fd..5dbd9f0c3 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -35,7 +35,6 @@ import { crossSaveParts, type SectionPersistContext, } from "../../B06_Section/B06_Section_UI_Page_Persist"; -import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; @@ -256,11 +255,8 @@ export async function mountCrossView( */ async function applyCrossHalfWidth(): Promise { if (!sectionDetail) return; - // 교차점이 샘플 밖인 측점이 있으면 그 폭까지 넓힌다(카드가 자동 줌아웃할 지반 확보). const requested = crossHalfWidth(); - const needed = maxToeFitHalfWidth(sectionDetail.cross_sections); - const target = Math.max(requested ?? 0, needed); - if (target > 0 && !(await ensureSampledWidth(target))) return; + if (requested && !(await ensureSampledWidth(requested))) return; if (!sectionDetail) return; stationControls.applyGlobalWidth( requested, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 49dc35c2e..70ea88540 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -28,6 +28,7 @@ export const FORM_OPTION_KEYS: Readonly> = { "inlet_structure", // 집수정 길이 · 전 · 후는 폼에서 빠짐(54-1) — 옛 값 그대로 둠 "inlet_basin_height_m", + "inlet_basin_animal", // 집수정 동물이동형(173-7) ...revetKeys("inlet"), "outlet_type", ...revetKeys("outlet"), diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 48bb3d5de..24ce66c42 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -64,8 +64,8 @@ _SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"}) # ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2 # 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다. MIN_PIPE_COVER_M = 0.5 -# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 30 cm(10-8 · 90-2). -CHUTE_THICKNESS_CM = 30.0 +# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 45 cm(173-5). +CHUTE_THICKNESS_CM = 45.0 # 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈 # 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다. diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index 0a0f6779b..4e9005562 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -9,8 +9,7 @@ * 교차점이 여전히 화면 밖에 남는다(2026-08-23 사용자 보고). * * 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면 - * 기울기와 지반 기울기 차이로 만나는 자리를 추정하고 1m 올림한다. 그 값이 계산 반폭을 - * 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. + * 기울기와 지반 기울기 차이로 만나는 자리를 추정한다. * ========================================================================== */ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; @@ -25,43 +24,10 @@ const TREND_SPAN_M = 2; /** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */ const MAX_FIT_HALF_WIDTH_M = 60; -/** - * 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null. - * - * 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다. - */ -export function toeFitHalfWidth(section: CrossSection): number | null { - const design = section.design; - if (!design) return null; - const groundAt = groundInterpolator(section.samples); - const designAt = designInterpolator(design.design_line); - const edges = design.road_edges; - if (!groundAt || !designAt || !edges) return null; - - // 설계선이 실제로 정의된 범위 — 그 밖은 외삽 영역이다. - const lineOffsets = design.design_line.map((point) => point.offset_m); - if (lineOffsets.length < 2) return null; - const lineMax = Math.max(...lineOffsets); - const lineMin = Math.min(...lineOffsets); - // 노면·측구 구간은 건너뛴다 — 계획고가 지반과 우연히 겹쳐도 사면 끝이 아니다. - const { protectMax, protectMin } = protectedSpan(design, edges); - - let extent = 0; - for (const side of ["left", "right"] as const) { - const outward = side === "left" ? 1 : -1; - const start = side === "left" ? protectMax : protectMin; - const limit = side === "left" ? lineMax : lineMin; - const meet = meetOffset(designAt, groundAt, start, limit, outward); - extent = Math.max(extent, Math.abs(meet)); - } - if (!(extent > 0)) return null; - return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); -} - /** * 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null. * - * `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) — + * 교차점을 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) — * 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다. * **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을 * 그리는 셈이 된다. @@ -129,7 +95,7 @@ export function fillSlopeLengths(section: CrossSection): { const outward = side === "left" ? 1 : -1; const start = side === "left" ? protectMax : protectMin; const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets); - // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가 + // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `meetOffset` 이 // 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다 // (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지). const meet = meetOffset(designAt, groundAt, start, limit, outward); @@ -328,8 +294,3 @@ function extrapolateMeet( if (!Number.isFinite(extra)) return limit; return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M); } - -/** 전 측점 중 교차점을 보려면 필요한 최대 반폭(m). 없으면 0. */ -export function maxToeFitHalfWidth(sections: CrossSection[]): number { - return sections.reduce((max, section) => Math.max(max, toeFitHalfWidth(section) ?? 0), 0); -} diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index d6b7a156f..be2df5118 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -37,7 +37,7 @@ const SECTION_KEYS: Record = { const SPAN_LINKED_TYPES = new Set(["ford_pavement"]); export const MIN_PIPE_COVER_M = 0.5; -export const CHUTE_THICKNESS_CM = 30.0; +export const CHUTE_THICKNESS_CM = 45.0; export const REVET_FACE_SLOPE = 0.3; export const INLET_STRUCTURE_BASIN = "집수정"; export const FORD_SLAB_THICKNESS_M = 0.3; @@ -185,7 +185,7 @@ function sideSpec( } /** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께 - * (cm → m · 빈 값 30 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ + * (cm → m · 빈 값 45 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ export function chute( values: Record, head = "chute_add", diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index fd51c39d4..6cc24ba67 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3332,6 +3332,7 @@ "inlet.height": "높이 (m)", "inlet.before": "기준측점 전 (m)", "inlet.after": "기준측점 후 (m)", + "inlet.animal": "동물이동형", "outlet.structure": "구조", "outlet.structure=기슭막이": "흙막이", "outlet.form": "형태", @@ -3362,7 +3363,7 @@ "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", "chute_add.thickness": "두께 (cm)", - "chute_add.bond": "붙임 형식", + "chute_add.bond": "형태", "chute_add.bond=찰붙임": "찰붙임", "chute_add.bond=메붙임": "메붙임", "chute_add.area": "면적 (㎡)" @@ -3429,7 +3430,7 @@ "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", "chute_add.thickness": "두께 (cm)", - "chute_add.bond": "붙임 형식", + "chute_add.bond": "형태", "chute_add.bond=찰붙임": "찰붙임", "chute_add.bond=메붙임": "메붙임", "chute_add.area": "면적 (㎡)" @@ -3504,7 +3505,7 @@ "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", "chute_add.thickness": "두께 (cm)", - "chute_add.bond": "붙임 형식", + "chute_add.bond": "형태", "chute_add.bond=찰붙임": "찰붙임", "chute_add.bond=메붙임": "메붙임", "chute_add.area": "면적 (㎡)" diff --git a/resources/tester/helper_116_right_click_add.cjs b/resources/tester/helper_116_right_click_add.cjs index cdd739d3f..d13906f8a 100644 --- a/resources/tester/helper_116_right_click_add.cjs +++ b/resources/tester/helper_116_right_click_add.cjs @@ -16,7 +16,10 @@ const fakeStorage = { globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; globalThis.sessionStorage = fakeStorage; globalThis.localStorage = fakeStorage; -const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 } }; +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 }, + "./ui_template_checkbox.css": {}, +}; function loadTs(file) { const full = path.resolve(file); diff --git a/resources/tester/helper_173_6_half_width.cjs b/resources/tester/helper_173_6_half_width.cjs new file mode 100644 index 000000000..766c4b026 --- /dev/null +++ b/resources/tester/helper_173_6_half_width.cjs @@ -0,0 +1,50 @@ +/* 횡단 보기 반폭 [적용](PLAN 173-6) 단위검증 헬퍼 — `createSampleWidener` 를 그 자리에서 트랜스파일해 돌린다. + * 보유 샘플(20 m) 안 요청은 재생성 없이 true · 넘는 요청만 재생성 한 번. 결과는 표준출력에 JSON 한 벌. + * 짝 = `test_173_6_half_width_apply.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 calls = { regenerate: [], overlay: 0 }; +const STUBS = { + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: () => [""] }), + }, + "@ui/ui_template_elements": { + showLoadingOverlay: () => calls.overlay++, + hideLoadingOverlay: () => {}, + showToast: () => {}, + }, + "./B06_Section_Api_Fetch": { + regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), + fetchSectionDetail: async () => ({ cross_sections: [] }), + }, + "./B06_Section_Section_Store": { replaceSectionDetail: () => {} }, +}; + +const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (r) => STUBS[r]); + +(async () => { + let sampled = 20; + const widen = box.exports.createSampleWidener({ + target: () => ({ projectId: "p", routeId: 1 }), + sampledHalfWidth: () => sampled, + applyDetail: () => (sampled = calls.regenerate.at(-1)), + }); + const out = {}; + out.within = [await widen(10), await widen(15), await widen(20)]; + out.withinCalls = calls.regenerate.length; + out.over = await widen(25); + out.overCalls = [...calls.regenerate]; + out.again = await widen(25); // 넓힌 뒤 같은 값은 재생성 없음 + out.againCalls = calls.regenerate.length; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_173_7_inlet_animal.cjs b/resources/tester/helper_173_7_inlet_animal.cjs new file mode 100644 index 000000000..609c9c8db --- /dev/null +++ b/resources/tester/helper_173_7_inlet_animal.cjs @@ -0,0 +1,152 @@ +/* 집수정 「동물이동형」 체크 칸(PLAN 173-7) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. + * 빈 값 = 체크 · 끄면 "false" · 다시 켜면 "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. + * 짝 = `test_173_7_inlet_animal.py` · 화면 그릇 흉내는 `helper_54_10_soil_tiers.cjs` 꼴(+ 듣개). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + this._on = {}; + this._text = ""; + this.value = ""; + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._classes.add(c)), + remove: (...n) => n.forEach((c) => self._classes.delete(c)), + toggle: (c, f) => + (f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c), + contains: (c) => self._classes.has(c), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent ?? "").join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k)); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + add(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener(type, fn) { + (this._on[type] ??= []).push(fn); + } + fire(type) { + (this._on[type] ?? []).forEach((fn) => fn({ target: this })); + } + closest() { + return null; + } + matches(sel) { + return sel.split(",").some((s) => { + s = s.trim(); + return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase(); + }); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + (n.children ?? []).forEach((k) => { + if (k.matches?.(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { createElement: (t) => new FakeNode(t) }; +globalThis.queueMicrotask = (fn) => fn(); +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +const STUBS = { + "@ui/ui_template_elements": { showToast: () => {} }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); + +const form = createSpecForm(null, {}); +const box = () => + form.root + .querySelectorAll("label") + .find((l) => l.dataset.specItem === "inlet.animal") + ?.querySelector("input") ?? null; +const animal = () => form.read().inlet_basin_animal ?? null; +const toggle = (on) => { + const input = box(); + input.checked = on; + input.fire("change"); +}; + +const out = {}; +form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정" }); +out.blank = { checked: box()?.checked ?? null, saved: animal() }; +toggle(false); +out.off = { checked: box().checked, saved: animal() }; +toggle(true); +out.on = { checked: box().checked, saved: animal() }; +form.write(PIPE_SPEC, { + pipe_diameter_mm: 1000, + inlet_type: "집수정", + inlet_basin_animal: "false", +}); +out.storedOff = { checked: box().checked, saved: animal() }; +form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "기슭막이" }); +out.soil = { shown: box() !== null, saved: animal() }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_54_10_soil_tiers.cjs b/resources/tester/helper_54_10_soil_tiers.cjs index 1ee678bda..aea1daad9 100644 --- a/resources/tester/helper_54_10_soil_tiers.cjs +++ b/resources/tester/helper_54_10_soil_tiers.cjs @@ -88,6 +88,7 @@ const toasts = []; const STUBS = { "@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) }, "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, "./B06_Section_UI_Page_Ford_Controls": { applyFordFormOptions: () => {}, applyBoxFormOptions: () => {}, diff --git a/resources/tester/helper_54_12_live_required.cjs b/resources/tester/helper_54_12_live_required.cjs index 4caaa0424..c0e2a1c68 100644 --- a/resources/tester/helper_54_12_live_required.cjs +++ b/resources/tester/helper_54_12_live_required.cjs @@ -17,7 +17,10 @@ const fakeStorage = { globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; globalThis.sessionStorage = fakeStorage; globalThis.localStorage = fakeStorage; -const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 } }; +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 }, + "./ui_template_checkbox.css": {}, +}; function loadTs(file) { const full = path.resolve(file); diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index abee5d6a7..3637338be 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -128,6 +128,7 @@ const PIPE_CALC = { }; const STUBS = { "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, "../A00_Common/design/design_structure_pipe_calc": PIPE_CALC, // 관 보호공 [연동] · [경사] 단추(54-30) — 여기서는 자리만 · 단 칸(90-1 공용)은 실제 파일 "./B05_Profile_UI_Panel_Fields": { toggleButton: () => new FakeNode("button") }, diff --git a/resources/tester/test_136_chute_form_cache.py b/resources/tester/test_136_chute_form_cache.py index 775499efd..f1f7cb39d 100644 --- a/resources/tester/test_136_chute_form_cache.py +++ b/resources/tester/test_136_chute_form_cache.py @@ -42,7 +42,7 @@ def test_되얹기는_상자_없으면_그대로_칸_전부면_지움(out: dict) def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None: - assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.3}] * 2 + assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.45}] * 2 assert {118, 120} <= set(out["boxAdd"]["refreshed"]) assert out["fordAdd"]["ford"] == {"structure": "돌붙임", "thickness_m": 0.5} assert out["fordAdd"]["refreshed"] == [160] @@ -51,5 +51,5 @@ def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None: def test_물넘이_포장은_ford_chute_만(out: dict) -> None: - assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.3}] * 2 + assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.45}] * 2 assert out["paveAdd"]["refreshed"] == [196, 200] diff --git a/resources/tester/test_173_6_half_width_apply.py b/resources/tester/test_173_6_half_width_apply.py new file mode 100644 index 000000000..e41790106 --- /dev/null +++ b/resources/tester/test_173_6_half_width_apply.py @@ -0,0 +1,48 @@ +"""173-6 횡단 보기 반폭 [적용] — 요청값만으로 샘플 폭을 잼(교차점 맞춤 폭 안 씀). + +원지반 못 만나는 측점이 있으면 누를 때마다 서버 재생성하던 옛 줄(`maxToeFitHalfWidth`)을 지움 → +보유 샘플(20 m) 넘는 값일 때만 재생성 · 나머지는 카드 다시 그리기만. +소스(`design_view_cross.ts` · `B06_Section_UI_Cross_Fit.ts`) + `createSampleWidener` 를 돌린 값 +(`helper_173_6_half_width.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +DVC = (ROOT / "A00_Common" / "design" / "design_view_cross.ts").read_text(encoding="utf-8") +FIT = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Fit.ts").read_text(encoding="utf-8") + + +def _apply_body() -> str: + at = DVC.index("async function applyCrossHalfWidth") + return DVC[at : DVC.index("\n }\n", at)] + + +def test_적용은_요청값만_넘김() -> None: + body = _apply_body() + assert "ensureSampledWidth(requested)" in body + assert "maxToeFitHalfWidth" not in DVC and "Math.max" not in body + + +def test_교차점_맞춤_폭_함수_지움() -> None: + assert "maxToeFitHalfWidth" not in FIT and "toeFitHalfWidth" not in FIT + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_보유_폭_안은_재생성_없음_넘을_때만_한_번() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_6_half_width.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["within"] == [True, True, True] and out["withinCalls"] == 0 + assert out["over"] is True and out["overCalls"] == [25] + assert out["again"] is True and out["againCalls"] == 1 diff --git a/resources/tester/test_173_7_inlet_animal.py b/resources/tester/test_173_7_inlet_animal.py new file mode 100644 index 000000000..b8539c476 --- /dev/null +++ b/resources/tester/test_173_7_inlet_animal.py @@ -0,0 +1,47 @@ +"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참. + +공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`) ++ 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_7_inlet_animal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_빈_값은_체크_저장_안_함(out: dict) -> None: + assert out["blank"] == {"checked": True, "saved": None} + + +def test_끄면_false_다시_켜면_true(out: dict) -> None: + assert out["off"] == {"checked": False, "saved": "false"} + assert out["on"] == {"checked": True, "saved": "true"} + assert out["storedOff"] == {"checked": False, "saved": "false"} + + +def test_흙막이엔_칸_없음(out: dict) -> None: + assert out["soil"] == {"shown": False, "saved": None} + + +def test_배관_폼_키_목록에_새_키() -> None: + merge = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts").read_text("utf-8") + assert '"inlet_basin_animal"' in merge diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 5d327f526..3fc31aa01 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -67,12 +67,12 @@ def test_유입_유출은_구조와_흙막이_칸만(out): "before", "after", ] - # 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2) · 붙임 형식 · 면적(98-3 · 기본 없음) + # 도수로/돌붙임 = 구조(도수로) · 형태(메붙임) · 두께(cm · 45) · 면적(㎡ · 30) — 173-5 assert out[spec]["chute_add"] == [ - ["structure", None, None], - ["thickness", None, "30"], - ["bond", None, None], - ["area", None, None], + ["structure", None, "도수로"], + ["bond", None, "메붙임"], + ["thickness", None, "45"], + ["area", None, "30"], ] keys = out[f"{spec}Keys"] assert keys["prefixes"] == ["soil_add_", "chute_add_"] diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index 72bc4e8e1..fd0882acd 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -23,9 +23,9 @@ from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402 def test_cross_reference_constants_match_plan(): - """계획서 3-0 표 값 — 토피 0.5m·전면 1:0.3 · 도수로/돌붙임 빈 두께 30 cm(90-2 사용자 확정).""" + """계획서 3-0 표 값 — 토피 0.5m·전면 1:0.3 · 도수로/돌붙임 빈 두께 45 cm(173-5 사용자 확정).""" assert MIN_PIPE_COVER_M == 0.5 - assert CHUTE_THICKNESS_CM == 30.0 + assert CHUTE_THICKNESS_CM == 45.0 assert REVET_FACE_SLOPE == 0.3 # 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정). assert "min_slope" not in _culvert_set(None) @@ -99,12 +99,12 @@ def test_old_default_moves_for_large_pipe(): def test_chute_thickness_cm_to_m(): - """도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음.""" + """도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 45 cm · 안 고르면 없음.""" assert "chute" not in _culvert_set({"chute_add_1_thickness": 40}) spec = _culvert_set({"chute_add_1_structure": "돌붙임", "chute_add_1_thickness": "45"}) assert spec["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} blank = _culvert_set({"chute_add_1_structure": "도수로", "chute_add_1_thickness": ""}) - assert blank["chute"] == {"structure": "도수로", "thickness_m": 0.3} + assert blank["chute"] == {"structure": "도수로", "thickness_m": 0.45} def test_attach_only_pipe_facilities(tmp_path): diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index c46295fcc..7ed0cc3fb 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -217,7 +217,7 @@ def test_ford_box_guard_attached_as_culvert() -> None: # 유입 날개벽 수평 여유 = 같은 칸(107-3) · 안 세운 날개벽엔 안 실림 wings = sections[60.0]["ford"] assert wings["wing_in"]["berm_m"] == 2 and "berm_m" not in wings["wing_out"] - assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3} + assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.45} assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} assert "chute" not in sections[40.0]["culvert"] assert sections[140.0]["ford_pavement"]["chute"] == {"structure": "돌붙임", "thickness_m": 0.4}