From 4313eda5acfb8ba68c4ee367b16a41e427fc9637 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 08:49:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(472-3=20=EB=93=B1=EB=A1=9D=EB=B6=80):=20B0?= =?UTF-8?q?5=20=C2=B7=20B06=20=EA=B3=84=EC=82=B0=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EB=B6=80=20=C2=B7=20=EC=84=9C=EB=B2=84=20Node=20=EC=9E=85?= =?UTF-8?q?=EA=B5=AC=20=ED=95=98=EB=82=98=20=C2=B7=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_calc_registry.ts — 순수 자료 · 1.1 표 # 마다 id 하나(43) · 입력 · 출력 · 실행 자리 · 단계 · 실행기 · 짝 · 거울 - design_calc_node.ts — id → 함수 표(구조물 면적 · 관 길이 · 누가토량 · 유토 배분 · 터파기 · 3D) · build:design-calc - 나머지 id 실행기 = 지금 파이썬 이름 · 472-4 동안 표시 - test_472_3_calc_registry — 실행기 있음 · STAGE_DEPS 같음 · 서버 쪽 코드 브라우저 밖 · Node 입구 도달 경계(알려진 끌림 목록) · server-calc · corridor 묶음과 같은 값 - 기존 묶음 · 파이썬 · design_pipeline.ts 그대로 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + A00_Common/design/CLAUDE.md | 2 +- A00_Common/design/logic/design_calc_node.ts | 156 ++++ .../design/logic/design_calc_registry.ts | 687 ++++++++++++++++++ package.json | 3 +- resources/tester/helper_472_3_graph.mjs | 51 ++ .../tester/helper_472_3_registry_entry.ts | 7 + resources/tester/test_472_3_calc_registry.py | 338 +++++++++ 8 files changed, 1244 insertions(+), 2 deletions(-) create mode 100644 A00_Common/design/logic/design_calc_node.ts create mode 100644 A00_Common/design/logic/design_calc_registry.ts create mode 100644 resources/tester/helper_472_3_graph.mjs create mode 100644 resources/tester/helper_472_3_registry_entry.ts create mode 100644 resources/tester/test_472_3_calc_registry.py diff --git a/.gitignore b/.gitignore index 36a98043f..907c78789 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,8 @@ config/spreadsheet_node/ config/b07_extent_node/ # 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1). config/b07_note_node/ +# 계산 등록부 서버 Node 입구 번들 — `npm run build:design-calc` 산출물(472-3). +config/design_calc_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/A00_Common/design/CLAUDE.md b/A00_Common/design/CLAUDE.md index 8d96894a0..23ec806ac 100644 --- a/A00_Common/design/CLAUDE.md +++ b/A00_Common/design/CLAUDE.md @@ -19,7 +19,7 @@ B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section 서버 Node 묶음: -- 입구 없음 · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state` +- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state` - 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험 남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts new file mode 100644 index 000000000..1708bcc3f --- /dev/null +++ b/A00_Common/design/logic/design_calc_node.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node.ts + * 계산 등록부(`design_calc_registry.ts`)의 서버 Node 입구 하나(PLAN 472-3) — id → 함수 표. + * 등록부에서 실행기가 `node:` 인 줄만 여기 있음(나머지는 472-4 동안 파이썬). + * 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음. + * + * 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`) + * 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 · + * context?: { earthwork_conversion · natural_spoil_min_ground_slope · + * haul_equipment_limits · 채집석 · 구조물 잔토(B08) } · + * excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m · + * has_foundation }] } } } + * 출력 { results: { [id]: 값 }, available: [id…] } + * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · + * server-calc 묶음과 같은 차례). + * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation"; +import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; +import type { MassHaulResult } from "@util/common_util_mass_haul"; +import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; +import type { RoutePoint } from "../../../B05_Profile/B05_Profile_Api_Fetch"; +import { buildCorridor } from "../../../B05_Profile/B05_Profile_UI_Corridor_Build"; +import { corridorHash, serialize } from "../../../B05_Profile/B05_Profile_UI_Corridor_Envelope"; +import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; +import { + applyStructureAreaRows, + extraWallRows, + structureAreaRows, +} from "../../../B06_Section/B06_Section_Structure_Layouts"; + +type HaulOptions = NonNullable[2]>; + +interface CalcInputs { + detail?: SectionDetailResponse; + route_points?: RoutePoint[]; + context?: Partial< + Pick< + HaulOptions, + | "collected_stone_deduction_m3" + | "collected_stone_by_ground_m3" + | "collected_stone_ground_unknown_m3" + | "structure_spoil_m3" + | "structure_spoil_points" + > + > & { + earthwork_conversion?: Parameters[1]; + natural_spoil_min_ground_slope?: number | null; + haul_equipment_limits?: Parameters[1]; + }; + excavation?: { + pipes?: Array<{ diameter_mm?: number | null }>; + walls?: Array<{ + height_m?: number | null; + average_thickness_m?: number | null; + has_foundation?: boolean; + }>; + }; +} + +/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */ +function runDesignCalc(run: readonly string[], inputs: CalcInputs) { + const memo = new Map(); + const sections = inputs.detail?.cross_sections ?? []; + const context = inputs.context ?? {}; + let massHaul: MassHaulResult | null = null; + + const RUNNERS: Record unknown> = { + // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. + "structure.layouts": () => { + const areas = structureAreaRows(sections); + applyStructureAreaRows(sections, areas); + return { areas, extra_walls: extraWallRows(sections) }; + }, + "masshaul.cumulative": () => { + need("structure.layouts"); + const conversion = context.earthwork_conversion; + massHaul = conversion + ? computeMassHaul(sections, conversion, context.natural_spoil_min_ground_slope ?? undefined) + : null; + return massHaul ? massHaulPayload(massHaul) : null; + }, + // 두 벌 — haul_plan = 거르지 않은 수량 정본(B08) · haul_plan_drawing = 잔진동 거른 그림. + "haul.plan": () => { + need("masshaul.cumulative"); + const result = massHaul; + if (!result) return null; + const planFor = (drawing: boolean) => + computeHaulPlan(result, context.haul_equipment_limits, { + collected_stone_deduction_m3: context.collected_stone_deduction_m3 ?? null, + collected_stone_by_ground_m3: context.collected_stone_by_ground_m3 ?? null, + collected_stone_ground_unknown_m3: context.collected_stone_ground_unknown_m3 ?? null, + structure_spoil_m3: context.structure_spoil_m3 ?? null, + structure_spoil_points: context.structure_spoil_points ?? null, + conversion: context.earthwork_conversion ?? null, + drawing, + }); + const plan = planFor(false); + const drawing = planFor(true); + return { + ...(plan ? { haul_plan: haulPlanPayload(plan) } : {}), + ...(drawing ? { haul_plan_drawing: haulPlanPayload(drawing) } : {}), + }; + }, + "excavation.trench": () => ({ + pipes: (inputs.excavation?.pipes ?? []).map((pipe) => ({ + width_m: pipeTrenchWidthM(pipe.diameter_mm), + })), + walls: (inputs.excavation?.walls ?? []).map((wall) => ({ + width_m: wallTrenchWidthM(wall.average_thickness_m), + area_m2: wallTrenchAreaM2( + wall.height_m, + wall.average_thickness_m, + wall.has_foundation !== false, + ), + })), + }), + // 편집 전 정본 — 계획선 샘플은 저장분 그대로(corridor 묶음과 같은 값) · 측점 둘 미만이면 null. + "corridor.mesh": () => { + const detail = inputs.detail; + if (!detail) return null; + const routePoints = inputs.route_points ?? []; + const samples = detail.longitudinal?.design_profiles?.[0]?.samples; + const build = buildCorridor(detail.cross_sections, routePoints, samples); + return build ? serialize(build, corridorHash(detail, routePoints)) : null; + }, + }; + + function need(id: string): unknown { + if (!memo.has(id)) memo.set(id, RUNNERS[id]()); + return memo.get(id); + } + + const unknown = run.filter((id) => !(id in RUNNERS)); + const results: Record = {}; + if (!unknown.length) for (const id of run) results[id] = need(id); + return { results, unknown, available: Object.keys(RUNNERS) }; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <묶음> <입력.json> <출력.json>"); + process.exit(2); +} +const request = JSON.parse(readFileSync(inputPath, "utf8")) as { + run?: string[]; + inputs?: CalcInputs; +}; +const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); +if (unknown.length) { + console.error(`모르는 id: ${unknown.join(", ")}`); + process.exit(4); +} +writeFileSync(outputPath, JSON.stringify({ results, available })); diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts new file mode 100644 index 000000000..e69d3c7da --- /dev/null +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -0,0 +1,687 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_registry.ts + * B05 · B06 계산 등록부(PLAN 472-3 · 요구 273) — 계산 한 갈래 = 줄 하나(목록 문서 + * `docs/raw/조사/2026-10-19_472_구조다시세움_목록.md` 1.1 표 # 하나). + * + * 순수 자료 — 함수 import 없음(타입만). 실행기는 글 이름이라 브라우저에 실려도 서버 쪽 코드가 + * 안 딸려감. 서버 Node 입구 = `design_calc_node.ts`(id → 함수 표 · `build:design-calc`). + * + * 칸: + * id — 갈래 이름(`<묶음>.<값>`) + * no — 목록 1.1 표 # + * inputs — 바뀌면 다시 셈 하는 값: `input.<묶음 입력>`(가게 칸) · 앞 id · `file:<정본>` · + * `session:<열쇠>` · `context.<서버 문맥>` + * outputs — 낳는 값 · 가게 칸(`store.<칸>`) · 서버 정본 자리(`file:` · `db:`) + * site — browser_now(브라우저 즉시 + 서버 정본 같은 셈) · server_canon(서버 정본만) · + * server_only(무거운 GIS · 래스터 · 추천 엔진) · browser_only(입력 도구 · 표시) + * stage — 브라우저 묶음 단계(`design_pipeline.ts`) · 없으면 null + * ts — TS 셈 자리 `<경로>#<함수>` · 없으면 null + * runner — 실행기: `node:`(서버 Node 입구) · `py:<경로>#<함수>`(지금 파이썬) · + * `browser:<경로>#<함수>`(browser_only 만) + * until — "472-4" = 파이썬 실행기는 472-4 짝 모으기 동안만(그 뒤 node:) + * pair — 파이썬 짝 `<경로>#<함수>` · 짝 아니면 null + * mirror — 거울 · 단위 시험(`resources/tester` 파일 이름 · .py 뺌) + * + * 묶음 단계의 기댐(`STAGE_DEPS`) = 같은 단계 줄들의 inputs 중 `input.*` · 다른 단계 id 의 단계 + * (같은지 시험 `test_472_3_calc_registry.py`). + * ========================================================================== */ + +import type { StageName } from "./design_pipeline_types"; + +export type CalcSite = "browser_now" | "server_canon" | "server_only" | "browser_only"; + +export interface CalcEntry { + id: string; + no: string; + label: string; + inputs: string[]; + outputs: string[]; + site: CalcSite; + stage: StageName | null; + ts: string | null; + runner: string; + until?: "472-4"; + pair: string | null; + mirror: string[]; + note?: string; +} + +const B05 = "B05_Profile/"; +const B05L = "B05_Profile/logic/"; +const B06 = "B06_Section/"; +const B06L = "B06_Section/logic/"; +const U = "common_util/"; + +export const CALC_REGISTRY: readonly CalcEntry[] = [ + /* ── 노선 ─────────────────────────────────────────────────────────────── */ + { + id: "route.polyline", + no: "R1", + label: "평면 폴리라인 · 곡선(BC · EC · MC)", + inputs: ["input.route"], + outputs: ["노선 선", "곡선표", "file:B05_Profile/route/*"], + site: "browser_now", + stage: "route", + ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#buildEditedPolyline`, + runner: `py:${U}common_util_route_polyline.py#build_planned_polyline`, + until: "472-4", + pair: `${U}common_util_route_polyline.py#build_planned_polyline`, + mirror: ["test_route_polyline_browser_mirror"], + note: "파이썬 짝은 추천 엔진 · B04 배수 · 노선 확인이 부름 — 짝 남음", + }, + { + id: "route.handle", + no: "R2", + label: "손잡이 → IP · R", + inputs: ["input.route"], + outputs: ["편집 입력"], + site: "browser_only", + stage: null, + ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#dragHandleTo`, + runner: `browser:${B05L}B05_Profile_UI_RouteEdit_Curve.ts#dragHandleTo`, + pair: null, + mirror: ["test_route_polyline_handle_drag"], + }, + { + id: "route.node_edits", + no: "R3", + label: "노드 편집 규칙(곡선 한도 · 고정점)", + inputs: ["input.route"], + outputs: ["고친 노드"], + site: "browser_now", + stage: "route", + ts: `${B05L}B05_Profile_UI_RouteEdit_Edits.ts#applyCurveLimits`, + runner: `py:${B05}B05_Profile_Route_NodeEdits.py#node_edits`, + until: "472-4", + pair: `${B05}B05_Profile_Route_NodeEdits.py#node_edits`, + mirror: ["test_430_route_edit_rules"], + }, + { + id: "route.station_fit", + no: "R4", + label: "스냅 맞춤(MC → 일반측점)", + inputs: ["input.route"], + outputs: ["IP 자리"], + site: "browser_only", + stage: null, + ts: `${B05L}B05_Profile_RouteEdit_StationFit.ts#fitCurveStations`, + runner: `browser:${B05L}B05_Profile_RouteEdit_StationFit.ts#fitCurveStations`, + pair: null, + mirror: ["test_366_3_station_fit"], + note: "짝 아님(머리 명시) — 서버는 저장 IP 그대로", + }, + /* ── 측점 ─────────────────────────────────────────────────────────────── */ + { + id: "stations.frame", + no: "R5", + label: "측점 틀(법선 · 방위 · 스냅 측점)", + inputs: ["route.polyline"], + outputs: ["측점 좌표 · 방위"], + site: "browser_now", + stage: "stations", + ts: `${B05L}B05_Profile_UI_RouteEdit_Frame.ts#stationFrameAt`, + runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + mirror: ["test_b05_routeedit_frame_mirror", "test_224_2_station_snap"], + }, + { + id: "stations.list", + no: "R6", + label: "측점 목록(일반 · 관 · 구조물 · 곡선 · 시종점)", + inputs: [ + "route.polyline", + "input.pipes", + "input.structures", + "input.plusStations", + "stations.frame", + "stations.key", + "facility.stations", + ], + outputs: ["측점 · 열쇠", "store.stations", "db:cross_sections"], + site: "browser_now", + stage: "stations", + ts: `${B05L}B05_Profile_Sections_Stations.ts#buildStations`, + runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + mirror: ["test_344_3_pipeline_stations_mirror", "test_407_curve_station"], + note: "파이썬 짝은 B03 체인 · B07 IP 표 · 추천도 부름", + }, + { + id: "stations.key", + no: "R7", + label: "측점 열쇠(grid:k · pipe: · structure:)", + inputs: ["stations.frame"], + outputs: ["측점 열쇠"], + site: "browser_now", + stage: "stations", + ts: `${U}common_util_station_key.ts#stationKey`, + runner: `py:${U}common_util_station_key.py#station_key`, + until: "472-4", + pair: `${U}common_util_station_key.py#station_key`, + mirror: ["test_344_3_station_key_mirror"], + }, + { + id: "stations.swap", + no: "R8", + label: "측점 갈아끼움(새 측점에 횡단 옮김)", + inputs: ["stations.list", "store.stations"], + outputs: ["초안 상세"], + site: "browser_only", + stage: null, + ts: `${B06L}B06_Section_Station_Swap.ts#swapCrossSections`, + runner: `browser:${B06L}B06_Section_Station_Swap.ts#swapCrossSections`, + pair: null, + mirror: ["test_346_2_station_swap", "test_272_swap_keeps_canon"], + note: "가게 stations 구독 · 서버는 처음부터 셈 — R6 이 서버와 한 벌이 되면 대부분 걷힘", + }, + /* ── 지반 ─────────────────────────────────────────────────────────────── */ + { + id: "ground.sample", + no: "G1", + label: "지반 표본", + inputs: ["stations.list", "file:dtm_grid"], + outputs: ["측점 표본"], + site: "browser_now", + stage: "ground", + ts: `${U}common_util_surface_sampler.ts#heightAt`, + runner: `py:${U}common_util_surface_sampler.py#build_surface_sampler`, + until: "472-4", + pair: `${U}common_util_surface_sampler.py#build_surface_sampler`, + mirror: ["test_344_1_surface_sampler_mirror"], + note: "X2 — 브라우저 격자 .bin · 서버 npz(값 협의)", + }, + { + id: "ground.extend", + no: "G2", + label: "미교차 넓힘 표본", + inputs: ["ground.sample", "cross.design"], + outputs: ["넓힌 표본"], + site: "browser_now", + stage: "cross", + ts: `${B05L}B05_Profile_Sections_Ground.ts#extendUnclosed`, + runner: `py:${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`, + until: "472-4", + pair: `${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`, + mirror: ["test_344_1_sections_ground_mirror"], + }, + { + id: "ground.widen", + no: "G3", + label: "넓힘(표본 폭 넘음)", + inputs: ["stations.list", "file:dtm_grid"], + outputs: ["표본 폭", "db:cross_sections"], + site: "server_only", + stage: null, + ts: null, + runner: `py:${B06}B06_Section_Router_Widen.py#widen_route_sections`, + pair: null, + mirror: [], + note: "저장 밖에서 DB 씀(N10) — 저장 시점 472-6 협의", + }, + { + id: "ground.uphill", + no: "G4", + label: "상단측(단면 유형 기본 재료)", + inputs: ["ground.sample"], + outputs: ["상단측"], + site: "browser_now", + stage: "cross", + ts: `${B05L}B05_Profile_Pipeline_Detail.ts#uphillSide`, + runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, + mirror: ["test_346_2_pipeline_cross_page"], + }, + /* ── 종단 ─────────────────────────────────────────────────────────────── */ + { + id: "profile.base", + no: "P1", + label: "첫 계획선(지반 따라감 base PVI)", + inputs: ["ground.sample"], + outputs: ["base PVI"], + site: "browser_now", + stage: "planline", + ts: `${B05L}B05_Profile_Grade_Ground.ts#groundFollowingBasePvi`, + runner: `py:${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`, + mirror: ["test_344_2_route_pairs", "test_432_1_curve_law"], + note: "파이썬 짝은 추천 엔진(RouteInitial_Profile)도 씀 — 짝 남음", + }, + { + id: "profile.alignment", + no: "P2", + label: "계획선 짓기(편집 → 꼭짓점 · 종단곡선)", + inputs: ["profile.base", "input.alignment"], + outputs: ["측점 계획고", "store.profile", "file:profile_alignment"], + site: "browser_now", + stage: "profile", + ts: `${B05L}B05_Profile_UI_Profile_Alignment.ts#buildAlignment`, + runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`, + mirror: [ + "test_426_profile_straight", + "test_432_1_curve_law", + "test_213_1_hidden_station_pvi", + "test_438_1_initial_straighten", + ], + }, + { + id: "profile.curve_law", + no: "P3", + label: "종단곡선 법칙(L · R = L/Δ · 큰 R 직선)", + inputs: ["profile.alignment"], + outputs: ["L · R"], + site: "browser_now", + stage: "profile", + ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#flatBends`, + runner: `py:${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`, + mirror: ["test_432_1_curve_law"], + }, + { + id: "profile.straight", + no: "P4", + label: "직선 판정", + inputs: ["profile.alignment"], + outputs: ["직선 꼭짓점"], + site: "browser_now", + stage: "profile", + ts: `${B05L}B05_Profile_Profile_Straight.ts#isStraightBend`, + runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`, + mirror: ["test_426_profile_straight", "test_271_straighten_pick"], + }, + { + id: "profile.carry", + no: "P5", + label: "노선 바뀜 계획고 옮김", + inputs: ["route.polyline"], + outputs: ["옮긴 편집(input.alignment 고침)"], + site: "browser_now", + stage: "follow", + ts: `${B05L}B05_Profile_Grade_Carry.ts#carryEdits`, + runner: `py:${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`, + mirror: ["test_400_profile_carry", "test_349_0v_route_save_span"], + }, + { + id: "profile.seat", + no: "P6", + label: "옮긴 시설 꼭짓점 세움", + inputs: ["route.polyline"], + outputs: ["꼭짓점(input.alignment 고침)"], + site: "browser_now", + stage: "follow", + ts: `${B05L}B05_Profile_Grade_Seat.ts#seatBornNodes`, + runner: `py:${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, + until: "472-4", + pair: `${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, + mirror: ["test_427_1_follow_seat", "test_459_route_seat_mirror"], + note: "Route_Save `_carry_profile` 도 같은 일", + }, + { + id: "profile.tools", + no: "P7", + label: "직선화 · 자유지정 · 쉬프트 · 시종점", + inputs: ["profile.alignment"], + outputs: ["편집 입력"], + site: "browser_only", + stage: null, + ts: `${B05L}B05_Profile_UI_Profile_Straighten.ts#straightenBetween`, + runner: `browser:${B05L}B05_Profile_UI_Profile_Straighten.ts#straightenBetween`, + pair: null, + mirror: [], + note: "편집값만 저장 → profile.alignment · 자유지정 design_profile_free · 쉬프트 Pivot · 시종점 EndOffsets", + }, + { + id: "profile.warnings", + no: "P8", + label: "종단 경고(최대 기울기 · 법정 L · 최소 R · 최소 피복)", + inputs: ["profile.alignment", "context.page_settings"], + outputs: ["경고 칩", "file:profile_alignment.warnings"], + site: "browser_now", + stage: null, + ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#verticalCurveShortfalls`, + runner: `py:${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`, + until: "472-4", + pair: `${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`, + mirror: ["test_442_2_grade_limit_mirror"], + note: "X5 — 기준표 두 벌(화면 PROFILE_CRITERIA · 서버 FOREST_ROAD_PROFILE_CRITERIA)", + }, + /* ── 횡단 ─────────────────────────────────────────────────────────────── */ + { + id: "cross.design", + no: "C1", + label: "횡단 설계선(노면 끝 · 미교차)", + inputs: ["profile.alignment", "ground.sample", "input.cross"], + outputs: ["설계선", "db:cross_sections.design"], + site: "browser_now", + stage: "cross", + ts: `${U}common_util_cross_design.ts#computeCrossDesign`, + runner: `py:${B06}B06_Section_Engine_Design.py#compute_cross_design`, + until: "472-4", + pair: `${B06}B06_Section_Engine_Design.py#compute_cross_design`, + mirror: ["test_b06_cross_design_mirror", "test_178_2_berm_fill_mirror"], + note: "파이썬 짝은 B07 · 추천 · Route_Save · B05 확정도 부름 — 짝 남음", + }, + { + id: "cross.areas", + no: "C2", + label: "단면적(절 · 성 · 측구 · 층따기)", + inputs: ["cross.design"], + outputs: ["측점 면적", "db:cross_sections.design"], + site: "browser_now", + stage: "cross", + ts: `${U}common_util_cross_design_areas.ts#splitCutAreas`, + runner: `py:${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, + until: "472-4", + pair: `${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, + mirror: ["test_b06_cross_design_mirror"], + note: "X3 — 표준 측점 = 파이썬 · 구조물 측점 = Node(structure.layouts)", + }, + { + id: "cross.enforce", + no: "C3", + label: "확정 보정(포장 구간 · 세월교 하강 · 기울임)", + inputs: ["cross.design", "input.structures"], + outputs: ["보정 설계"], + site: "browser_now", + stage: "cross", + ts: `${B06L}B06_Section_Cross_Enforce.ts#facilityRanges`, + runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`, + until: "472-4", + pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`, + mirror: ["test_349_0n_cross_enforce_mirror"], + }, + { + id: "cross.berm_spans", + no: "C4", + label: "소단 구간(구조물 → 구간)", + inputs: ["input.structures"], + outputs: ["소단 구간"], + site: "browser_now", + stage: null, + ts: `${B06}B06_Section_UI_Page_Persist.ts#bermSpansFromStructures`, + runner: `py:${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, + until: "472-4", + pair: `${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, + mirror: ["test_272_berm_source"], + note: "TS 가 화면 파일 안 — common_util 로(472-4 ②)", + }, + { + id: "cross.berm_slope", + no: "C5", + label: "소단 사면 점", + inputs: ["cross.design"], + outputs: ["사면 꺾임"], + site: "browser_now", + stage: "cross", + ts: `${U}common_util_cross_berm.ts#cutProfilePoints`, + runner: `py:${U}common_util_cross_berm.py#cut_profile_points`, + until: "472-4", + pair: `${U}common_util_cross_berm.py#cut_profile_points`, + mirror: ["test_178_2_berm_fill_mirror"], + }, + { + id: "cross.underdrain", + no: "C6", + label: "맹암거 얹기 · 기울임", + inputs: ["cross.design", "input.structures"], + outputs: ["underdrain 칸"], + site: "browser_now", + stage: "cross", + ts: `${U}common_util_structure_underdrain.ts#attachUnderdrainSpecs`, + runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, + until: "472-4", + pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, + mirror: [], + note: "거울 시험 없음 — 옮기기 전 먼저", + }, + { + id: "cross.fit", + no: "C7", + label: "반폭 맞춤 · 교차점 손잡이 · 성토 5 m 경고", + inputs: ["cross.design"], + outputs: ["표시 반폭 · 경고"], + site: "browser_only", + stage: null, + ts: `${B06L}B06_Section_UI_Cross_Fit.ts#toeOffsets`, + runner: `browser:${B06L}B06_Section_UI_Cross_Fit.ts#toeOffsets`, + pair: null, + mirror: [], + }, + /* ── 시설 ─────────────────────────────────────────────────────────────── */ + { + id: "facility.follow", + no: "F1", + label: "시설 따라감(노선 바뀜 → 관 · 구조물 자리)", + inputs: ["route.polyline"], + outputs: ["새 좌표 · 누가거리(input.pipes · input.structures 고침)"], + site: "browser_now", + stage: "follow", + ts: `${U}common_util_route_follow.ts#followStructures`, + runner: `py:${U}common_util_route_follow.py#follow_structures`, + until: "472-4", + pair: `${U}common_util_route_follow.py#follow_structures`, + mirror: ["test_410_follow_xy", "test_344_2_route_pairs", "test_459_route_seat_mirror"], + }, + { + id: "facility.plus_follow", + no: "F2", + label: "추가 측점 따라감", + inputs: ["route.polyline"], + outputs: ["추가 측점(input.plusStations 고침)"], + site: "browser_now", + stage: "follow", + ts: `${B05}B05_Profile_Api_PlusStations.ts#followPlusDraft`, + runner: `py:${B05}B05_Profile_Plus_Stations.py#follow_plus_stations`, + until: "472-4", + pair: `${B05}B05_Profile_Plus_Stations.py#follow_plus_stations`, + mirror: ["test_459_route_seat_mirror"], + note: "TS 가 Api 파일 안 — logic 으로(472-4 ①)", + }, + { + id: "facility.stations", + no: "F3", + label: "시설 측점 계획", + inputs: ["route.polyline", "input.pipes", "input.structures"], + outputs: ["시설 측점"], + site: "browser_now", + stage: "stations", + ts: `${U}common_util_structure_station.ts#planStations`, + runner: `py:${U}common_util_structure_station.py#plan_stations`, + until: "472-4", + pair: `${U}common_util_structure_station.py#plan_stations`, + mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station"], + note: "파이썬 짝은 B07 · 배수가 넓게 부름", + }, + { + id: "facility.attach", + no: "F4", + label: "형상 얹기(관 세트 · 벽 · 포장 · 맹암거 그림 자료)", + inputs: ["input.pipes", "input.structures", "cross.design"], + outputs: ["그림 자료"], + site: "browser_now", + stage: "cross", + ts: `${U}common_util_structure_attach.ts#attachShapes`, + runner: `py:${B06}B06_Section_Engine_Attach.py#attach_shapes`, + until: "472-4", + pair: `${B06}B06_Section_Engine_Attach.py#attach_shapes`, + mirror: ["test_323_4_attach_mirror"], + }, + { + id: "facility.culvert_sets", + no: "F5", + label: "관 세트(관경 · 날개벽 · 집수정)", + inputs: ["input.pipes"], + outputs: ["관 세트"], + site: "browser_now", + stage: null, + ts: `${U}common_util_culvert_sets.ts#pipeWallHeightM`, + runner: `py:${B06}B06_Section_Engine_Culvert.py#pipe_wall_height_m`, + pair: `${B06}B06_Section_Engine_Culvert.py#pipe_wall_height_m`, + mirror: ["test_b06_culvert_sets_mirror", "test_b06_pipe_wall_height_mirror"], + note: "짝 유지 — 파이썬 소비자 B04 · B07 · B08(472 밖)", + }, + { + id: "facility.walls", + no: "F6", + label: "벽 · 포장", + inputs: ["input.structures"], + outputs: ["벽 · 포장"], + site: "browser_now", + stage: null, + ts: `${U}common_util_structure_walls.ts#wallSpecsFrom`, + runner: `py:${B06}B06_Section_Engine_Structures_Wall.py#wall_specs_from`, + pair: `${B06}B06_Section_Engine_Structures_Wall.py#wall_specs_from`, + mirror: ["test_b06_structure_walls_mirror", "test_b06_structure_pavement_mirror"], + note: "짝 유지 — 포장 짝 common_util_structure_pavement(.py · .ts) · B07 소비", + }, + { + id: "structure.layouts", + no: "F7", + label: "관 길이 · 구조물 폐회로 면적 · 다단 벽", + inputs: ["cross.design", "cross.areas", "facility.attach"], + outputs: ["관 길이 · 구조물 면적 · 선 다단 벽", "db:cross_sections.design"], + site: "browser_now", + stage: null, + ts: `${B06}B06_Section_Structure_Layouts.ts#structureAreaRows`, + runner: "node:structure.layouts", + pair: null, + mirror: ["test_b06_pipe_length", "test_299_ford_span_pipe"], + }, + { + id: "excavation.trench", + no: "F8", + label: "터파기(관 폭 · 벽 단면)", + inputs: ["facility.culvert_sets", "facility.walls"], + outputs: ["터파기 폭 · 단면적"], + site: "browser_now", + stage: null, + ts: `${U}common_util_excavation.ts#wallTrenchAreaM2`, + runner: "node:excavation.trench", + pair: `${U}common_util_excavation.py#wall_trench_area_m2`, + mirror: ["test_excavation_mirror"], + note: "파이썬 짝 운영 부름 0 — 걷음 후보(472-7)", + }, + { + id: "facility.ford", + no: "F9", + label: "세월교 높이 · 깊이", + inputs: ["input.structures"], + outputs: ["높이"], + site: "browser_now", + stage: null, + ts: "A00_Common/design/design_structure_ford_calc.ts#fordHeightM", + runner: "py:B04_PreProcess/B04_PreProcess_Router_Basins.py#ford_height_m", + pair: "B04_PreProcess/B04_PreProcess_Router_Basins.py#ford_height_m", + mirror: ["test_65_ford_defaults", "test_73_11_b08_hydro_box_ford"], + note: "짝 유지(B04 · B08)", + }, + /* ── 토량 ─────────────────────────────────────────────────────────────── */ + { + id: "masshaul.cumulative", + no: "E1", + label: "누가토량", + inputs: ["cross.areas", "structure.layouts", "context.earthwork_conversion"], + outputs: ["누가토량 · 배지", "file:longitudinal.mass_haul"], + site: "browser_now", + stage: null, + ts: `${U}common_util_mass_haul.ts#computeMassHaul`, + runner: "node:masshaul.cumulative", + pair: null, + mirror: [], + note: "브라우저는 그릴 때 셈(B05_Profile_UI_Profile_MassHaul) · 같은 코드", + }, + { + id: "haul.plan", + no: "E2", + label: "유토 배분 · 운반거리", + inputs: ["masshaul.cumulative", "context.haul_equipment_limits"], + outputs: ["배분", "file:longitudinal.mass_haul.haul_plan"], + site: "server_canon", + stage: null, + ts: `${U}common_util_mass_haul_balance.ts#computeHaulPlan`, + runner: "node:haul.plan", + pair: null, + mirror: [], + note: "브라우저 번들 밖(/haul-plan 요청) · 브라우저 실행 여부 협의", + }, + /* ── 배수 · 3D · 추천 ────────────────────────────────────────────────── */ + { + id: "drainage.basins", + no: "D1", + label: "배수유역 세부 나눔", + inputs: ["route.polyline", "input.pipes"], + outputs: ["유역 · 관경 제안"], + site: "server_only", + stage: "drainage", + ts: null, + runner: `py:${U}common_util_drainage_detail.py#build_detail`, + pair: null, + mirror: [], + note: "TS 는 일정만(B05_Profile/logic/B05_Profile_Drainage_Live.ts)", + }, + { + id: "drainage.suggest", + no: "D2", + label: "관 자리 제안", + inputs: ["route.polyline", "drainage.basins"], + outputs: ["제안"], + site: "server_only", + stage: null, + ts: null, + runner: `py:${U}common_util_drainage_detail.py#suggest_pipe_positions`, + pair: null, + mirror: [], + note: "TS 는 모으기만(B05_Profile/logic/B05_Profile_Drainage_Suggest.ts)", + }, + { + id: "corridor.mesh", + no: "D3", + label: "3D 길 모형", + inputs: ["profile.alignment", "cross.design"], + outputs: ["3D", "file:corridor"], + site: "browser_now", + stage: "corridor", + ts: `${B05}B05_Profile_UI_Corridor_Build.ts#buildCorridor`, + runner: "node:corridor.mesh", + pair: null, + mirror: [], + }, + { + id: "footprint.plan", + no: "D4", + label: "평면 발자국(절성토선)", + inputs: ["cross.design", "ground.sample"], + outputs: ["발자국", "store.footprint"], + site: "browser_only", + stage: "footprint", + ts: `${B05L}B05_Profile_PlanFootprint.ts#footprintRecord`, + runner: `browser:${B05L}B05_Profile_Footprint_Stage.ts#footprintStage`, + pair: "B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py#route_frame", + mirror: ["test_346_2_plan_footprint_mirror"], + note: "서버 정본 없음(즉시 후보) · X4 B07 파이썬이 따로 셈(472 밖)", + }, + { + id: "route.recommend", + no: "N1", + label: "노선 추천", + inputs: ["file:dtm_grid", "file:streams"], + outputs: ["추천 노선"], + site: "server_only", + stage: null, + ts: null, + runner: `py:${B05}B05_Profile_Engine_RouteInitial.py#generate_initial_route`, + pair: null, + mirror: ["test_419_1_recommend_engine"], + note: "[적용] 정본은 route.polyline · stations.list 길 · TS 는 보기만(B05_Profile_UI_RouteEdit_Initial_Model.ts)", + }, +]; diff --git a/package.json b/package.json index 06f974a03..74658cf6e 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,14 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:design-calc && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", "build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node", + "build:design-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../A00_Common/design/logic/design_calc_node.ts --outDir ../config/design_calc_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/resources/tester/helper_472_3_graph.mjs b/resources/tester/helper_472_3_graph.mjs new file mode 100644 index 000000000..9714d0c1c --- /dev/null +++ b/resources/tester/helper_472_3_graph.mjs @@ -0,0 +1,51 @@ +/* 472-3 헬퍼 — TS import 도달 집합(472-0 g472/graph.mjs 꼴 · 읽기만). + * node helper_472_3_graph.mjs <저장소 뿌리> <입구.ts> [<입구.ts> …] + * 입구마다 도달 파일(뿌리 상대 · `/`)을 JSON 한 벌로 표준출력 — 정적 import · export from · 동적 + * import() · new URL(…, import.meta.url)(워커) · `import type` · `export type` 은 뺌(지워짐). */ +import fs from "node:fs"; +import path from "node:path"; + +const [root, ...entries] = process.argv.slice(2); +const alias = { "@ui/": "ui_template/", "@config/": "config/", "@util/": "common_util/" }; +const rel = (p) => path.relative(root, p).split(path.sep).join("/"); +const exists = (p) => { + for (const c of [p, `${p}.ts`, `${p}/index.ts`]) + if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + return null; +}; +function resolve(from, spec) { + let base; + if (spec.startsWith(".")) base = path.resolve(path.dirname(from), spec); + else { + const a = Object.keys(alias).find((k) => spec.startsWith(k)); + if (!a) return null; + base = path.resolve(root, alias[a] + spec.slice(a.length)); + } + return exists(base.replace(/\.js$/, "")); +} +const re = + /(?:import|export)\s+(?:type\s+)?(?:[^;"'`]*?\sfrom\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|new URL\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url/g; +function deps(file) { + if (!/\.(ts|mts)$/.test(file)) return []; + const src = fs.readFileSync(file, "utf8"); + const out = []; + for (const m of src.matchAll(re)) { + if (/^(?:import|export)\s+type\s/.test(m[0])) continue; + const target = resolve(file, m[1] || m[2] || m[3]); + if (target) out.push(target); + } + return out; +} +const result = {}; +for (const entry of entries) { + const seen = new Set(); + const stack = [path.resolve(root, entry)]; + while (stack.length) { + const file = stack.pop(); + if (seen.has(file)) continue; + seen.add(file); + stack.push(...deps(file)); + } + result[entry] = [...seen].map(rel).sort(); +} +process.stdout.write(JSON.stringify(result)); diff --git a/resources/tester/helper_472_3_registry_entry.ts b/resources/tester/helper_472_3_registry_entry.ts new file mode 100644 index 000000000..0b6311719 --- /dev/null +++ b/resources/tester/helper_472_3_registry_entry.ts @@ -0,0 +1,7 @@ +/* 472-3 헬퍼 — 등록부와 묶음 단계 기댐을 한 묶음으로(vite ssr) · test_472_3_calc_registry.py 가 JSON 으로 받음. */ +export { CALC_REGISTRY } from "../../A00_Common/design/logic/design_calc_registry"; +export { + AFTER_STAGES, + BROWSER_STAGES, + STAGE_DEPS, +} from "../../A00_Common/design/logic/design_pipeline"; diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py new file mode 100644 index 000000000..722abba13 --- /dev/null +++ b/resources/tester/test_472_3_calc_registry.py @@ -0,0 +1,338 @@ +"""PLAN 472-3 (요구 273) — 계산 등록부 · 서버 Node 입구 · 경계 시험. + +등록부 = `A00_Common/design/logic/design_calc_registry.ts`(순수 자료 · 목록 1.1 표 # 하나 = id 하나) +Node 입구 = `A00_Common/design/logic/design_calc_node.ts`(`build:design-calc` · id → 함수 표). + +못박는 것 + - id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43). + - id 마다 실행기 있음 — `node:` 는 입구 표에 · `py:` · `browser:` · ts · 짝 이름은 그 파일에 · + 거울 시험 파일 있음. + - 묶음 `STAGE_DEPS`(`design_pipeline.ts`) = 등록부 입력에서 만든 기댐(같은지만 · 묶음 안 고침). + - 서버 쪽(server_canon · server_only) TS 가 브라우저 import 그래프에 없음 · 등록부는 안 끔. + - Node 입구 도달 집합 = logic · common_util · 자료만 + 「알려진 끌림」(`KNOWN_PULLS`) — 그 밖은 + 실패 · 안 나타난 알려진 끌림도 실패(목록에서 지움 — 472-2 2-B · 472-4 가 하나씩 걷어 빈 목록). + - Node 입구 값 = 기존 묶음 값(고정 자료 `fixtures/golden_472` · server-calc 면적 · 다단 벽 · + 누가토량 · 배분 · corridor 3D) · 터파기 = 파이썬 짝. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_3_calc_registry.py -q +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +FIXTURE = HERE / "fixtures" / "golden_472" +REGISTRY = "A00_Common/design/logic/design_calc_registry.ts" +NODE_ENTRY = "A00_Common/design/logic/design_calc_node.ts" +BROWSER_ENTRY = "A00_Common/main.ts" +BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js" +NODE_IDS = { + "structure.layouts", + "masshaul.cumulative", + "haul.plan", + "excavation.trench", + "corridor.mesh", +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 정리(PIPE_DISPLAY_NAME → common_util · Api_Fetch 세션 · +# 구조물 그림이 고른 것 읽기 · 사진첩). +SESSION_PULLS = ( + "A00_Common/b_page_state.ts", + "A00_Common/design/design_pick_match.ts", + "A00_Common/design/design_pipe_ids.ts", + "A00_Common/design/design_selection.ts", + "A00_Common/design/design_store.ts", + "B05_Profile/B05_Profile_UI_IrregularStations.ts", + "B05_Profile/B05_Profile_UI_Profile_History.ts", + "B06_Section/B06_Section_Api_Fetch.ts", + "B06_Section/B06_Section_UI_Section_Common.ts", + "config/config_frontend.ts", + "ui_template/ui_template_elements.ts", + "ui_template/ui_template_elements_base.ts", + "ui_template/ui_template_elements_chart.ts", + "ui_template/ui_template_elements_styles.ts", + "ui_template/ui_template_locale.ts", + "ui_template/ui_template_locale_a.ts", + "ui_template/ui_template_locale_b1.ts", + "ui_template/ui_template_locale_b2.ts", + "ui_template/ui_template_locale_b3.ts", + "ui_template/ui_template_locale_b4.ts", + "ui_template/ui_template_locale_common.ts", + "ui_template/ui_template_locale_m1.ts", + "ui_template/ui_template_locale_m2.ts", +) +# 기하가 logic 밖(구조물 그림 · 3D 빌더 파일 안) — 472-4 가 logic · common_util 로. +GEOMETRY_PULLS = ( + "B05_Profile/B05_Profile_UI_Corridor_Build.ts", + "B05_Profile/B05_Profile_UI_Corridor_Envelope.ts", + "B05_Profile/B05_Profile_UI_Corridor_Station.ts", + "B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts", + "B05_Profile/B05_Profile_UI_Corridor_Structures.ts", + "B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts", + "B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts", + "B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts", + "B06_Section/B06_Section_Structure_Layouts.ts", + "B06_Section/B06_Section_UI_Cross_Body_Guard.ts", + "B06_Section/B06_Section_UI_Cross_Box.ts", + "B06_Section/B06_Section_UI_Cross_Box_Geom.ts", + "B06_Section/B06_Section_UI_Cross_Culvert.ts", + "B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts", + "B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts", + "B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts", + "B06_Section/B06_Section_UI_Cross_Culvert_Types.ts", + "B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts", + "B06_Section/B06_Section_UI_Cross_Design_Surface.ts", + "B06_Section/B06_Section_UI_Cross_Excavation.ts", + "B06_Section/B06_Section_UI_Cross_Ford.ts", + "B06_Section/B06_Section_UI_Cross_Ford_Geom.ts", + "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts", + "B06_Section/B06_Section_UI_Cross_Revetment.ts", + "B06_Section/B06_Section_UI_Cross_Wall.ts", + "B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts", +) +KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) + + +def _node(*args: str, timeout: int = 120) -> str: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout + ) + if proc.returncode: + raise RuntimeError(proc.stderr) + return proc.stdout + + +@pytest.fixture(scope="module") +def loaded(tmp_path_factory) -> dict: + """vite ssr 로 등록부 + 묶음 단계 기댐을 묶어 JSON 으로(472-1 꼴).""" + work = tmp_path_factory.mktemp("reg") + _node( + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_3_registry_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_3_registry_entry.js", bundle) + url = bundle.as_uri() + out = _node( + "--input-type=module", + "-e", + f"const m = await import({json.dumps(url)}); console.log(JSON.stringify(" + "{registry: m.CALC_REGISTRY, deps: m.STAGE_DEPS, " + "stages: [...m.BROWSER_STAGES, ...m.AFTER_STAGES]}))", + ) + return json.loads(out) + + +@pytest.fixture(scope="module") +def reach() -> dict[str, list[str]]: + out = _node( + str(HERE / "helper_472_3_graph.mjs"), str(ROOT), NODE_ENTRY, REGISTRY, BROWSER_ENTRY + ) + return json.loads(out) + + +def _run(payload: dict) -> dict | None: + from common_util.common_util_node_bundle import run_bundle_json + + return run_bundle_json(BUNDLE, "build:design-calc", payload) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _context() -> dict: + ctx = _fixture("context.json") + keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope") + return {k: ctx[k] for k in keys} + + +def _has_name(ref: str) -> bool: + """`<경로>#<이름>` — 파일 있음 · 그 이름이 정의됨(py def · ts export).""" + path, name = ref.split("#") + file = ROOT / path + if not file.is_file(): + return False + src = file.read_text(encoding="utf-8") + if path.endswith(".py"): + return re.search(rf"^\s*(?:async\s+)?def {re.escape(name)}\(", src, re.M) is not None + pattern = rf"^export\s+(?:async\s+)?(?:function\*?|const|let|class)\s+{re.escape(name)}\b" + return re.search(pattern, src, re.M) is not None + + +# ── 등록부 자료 ────────────────────────────────────────────────────────────── + + +def test_id_하나씩_표_전부(loaded) -> None: + rows = loaded["registry"] + ids = [row["id"] for row in rows] + nos = [row["no"] for row in rows] + assert len(ids) == len(set(ids)) + assert len(nos) == len(set(nos)) + want = {f"{k}{i}" for k, n in (("R", 8), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)} + want |= {f"F{i}" for i in range(1, 10)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"} + assert set(nos) == want + + +def test_id_마다_실행기_있음(loaded) -> None: + bad = [] + for row in loaded["registry"]: + kind, _, ref = row["runner"].partition(":") + if row["site"] == "browser_only": + ok = kind == "browser" and _has_name(ref) + elif kind == "node": + ok = ref == row["id"] and ref in NODE_IDS + else: + ok = kind == "py" and _has_name(ref) + ok = ok and ("until" not in row or kind == "py") + ok = ok and all(_has_name(r) for r in (row["ts"], row["pair"]) if r) + ok = ok and all((HERE / f"{name}.py").is_file() for name in row["mirror"]) + if not ok: + bad.append(row["id"]) + assert bad == [] + node_rows = {row["id"] for row in loaded["registry"] if row["runner"].startswith("node:")} + assert node_rows == NODE_IDS + + +def test_묶음_STAGE_DEPS_등록부_입력과_같음(loaded) -> None: + """같은 단계 줄들의 inputs 중 `input.*` 은 그대로 · 다른 단계 id 는 그 단계 이름.""" + rows = loaded["registry"] + stage_of = {row["id"]: row["stage"] for row in rows} + derived: dict[str, set[str]] = {name: set() for name in loaded["stages"]} + for row in rows: + if not row["stage"]: + continue + for item in row["inputs"]: + if item.startswith("input."): + derived[row["stage"]].add(item) + elif stage_of.get(item) and stage_of[item] != row["stage"]: + derived[row["stage"]].add(stage_of[item]) + assert {k: set(v) for k, v in loaded["deps"].items()} == derived + # 앞 id 는 등록부에 있음(이름 틀림 막기) + ids = set(stage_of) + prefixes = ("input.", "file:", "session:", "context.", "store.") + stray = [ + i for row in rows for i in row["inputs"] if not i.startswith(prefixes) and i not in ids + ] + assert stray == [] + + +# ── import 경계 ───────────────────────────────────────────────────────────── + + +def test_서버_쪽_코드_브라우저_밖(loaded, reach) -> None: + browser = set(reach[BROWSER_ENTRY]) + server = [ + row["ts"].split("#")[0] + for row in loaded["registry"] + if row["site"] in ("server_canon", "server_only") and row["ts"] + ] + assert server # 유토 배분(common_util_mass_haul_balance) + assert [path for path in server if path in browser] == [] + assert NODE_ENTRY not in browser + assert reach[REGISTRY] == [REGISTRY] # 순수 자료 — 아무것도 안 끔 + + +def test_Node_입구_도달_경계(reach) -> None: + files = set(reach[NODE_ENTRY]) + own = { + f for f in files if "/logic/" in f or f.startswith("common_util/") or f.endswith(".json") + } + outside = files - own + assert sorted(outside - KNOWN_PULLS) == [] # 새 끌림 + assert sorted(KNOWN_PULLS - outside) == [] # 걷힌 끌림 — KNOWN_PULLS 에서 지울 것 + + +# ── Node 입구 값 ──────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def golden_run() -> dict: + out = _run( + { + "run": ["haul.plan", "structure.layouts", "masshaul.cumulative"], + "inputs": {"detail": _fixture("detail.json"), "context": _context()}, + } + ) + assert isinstance(out, dict) + assert set(out["available"]) == NODE_IDS + return out["results"] + + +def test_server_calc_묶음과_같음(golden_run) -> None: + from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT + from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE as SC_BUNDLE + from common_util.common_util_node_bundle import run_bundle_json + + sc = run_bundle_json( + SC_BUNDLE, _NPM_SCRIPT, {"detail": _fixture("detail.json"), "context": _context()} + ) + layouts = golden_run["structure.layouts"] + assert layouts["areas"] == sc["areas"] and sc["areas"] + assert layouts["extra_walls"] == sc["extra_walls"] + merged = {**golden_run["masshaul.cumulative"], **golden_run["haul.plan"]} + assert merged == sc["mass_haul"] + assert merged["final_cumulative_m3"] == -17570.7 # 472-1 화면 참값 + + +def test_corridor_묶음과_같음() -> None: + from B05_Profile.B05_Profile_Corridor_Prebuild import BUNDLE as CO_BUNDLE + from common_util.common_util_node_bundle import run_bundle_json + + detail = _fixture("detail.json") + points = [ + {"x": x, "y": y, "order": i} for i, (x, y) in enumerate(_fixture("plan.json")["planned"]) + ] + co = run_bundle_json(CO_BUNDLE, "build:corridor", {"detail": detail, "route_points": points}) + out = _run({"run": ["corridor.mesh"], "inputs": {"detail": detail, "route_points": points}}) + assert co and out["results"]["corridor.mesh"] == co + + +def test_터파기_파이썬_짝과_같음() -> None: + from common_util import common_util_excavation as py + + pipes = [{"diameter_mm": d} for d in (300, 800, 1500, 250, None)] + walls = [ + {"height_m": 2.0, "average_thickness_m": 0.5, "has_foundation": True}, + {"height_m": 1.2, "average_thickness_m": 0.35, "has_foundation": False}, + {"height_m": None, "average_thickness_m": 0.4, "has_foundation": True}, + ] + out = _run( + {"run": ["excavation.trench"], "inputs": {"excavation": {"pipes": pipes, "walls": walls}}} + ) + got = out["results"]["excavation.trench"] + assert [p["width_m"] for p in got["pipes"]] == [ + py.pipe_trench_width_m(p["diameter_mm"]) for p in pipes + ] + for row, wall in zip(got["walls"], walls, strict=True): + assert row["width_m"] == py.wall_trench_width_m(wall["average_thickness_m"]) + want = py.wall_trench_area_m2( + wall["height_m"], wall["average_thickness_m"], has_foundation=wall["has_foundation"] + ) + assert row["area_m2"] == pytest.approx(want) if want is not None else row["area_m2"] is None + + +def test_모르는_id_는_실패() -> None: + assert _run({"run": ["stations.list"], "inputs": {}}) is None