From f9f606622fb03db654894d1f9f45969527ea1118 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 13:49:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(511-B=20B-1=20=EB=8B=A8=EA=B3=84=20Node=20?= =?UTF-8?q?=EC=9E=85=EA=B5=AC=20=C2=B7=20=ED=8C=90=20=EB=8B=A4=EC=84=AF=20?= =?UTF-8?q?=EC=A4=84):=20stage.route=20=C2=B7=20follow=20=C2=B7=20stations?= =?UTF-8?q?=20=C2=B7=20profile=20=C2=B7=20cross=20=3D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=AC=B6=EC=9D=8C=20=EB=8B=A8=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20Node=20=EA=B0=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=ED=95=A8=EC=88=98=20=C2=B7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=B6=99=EC=9E=84=EC=9C=BC=EB=A1=9C=20=EC=85=88(de?= =?UTF-8?q?sign=5Fcalc=5Fnode=5Fstages.ts=20=C2=B7=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EB=B6=80=20=EB=B0=96=20STAGE=5FIDS=20=C2=B7=20=EC=B0=BD?= =?UTF-8?q?=EA=B5=AC=EA=B0=80=20=EB=B0=9B=EC=9D=8C=20=C2=B7=20=ED=95=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=3D=20=EB=AC=B6=EC=9D=8C=20=ED=95=9C=20?= =?UTF-8?q?=EB=B0=94=ED=80=B4=20=C2=B7=20DTM=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EA=B8=B0=EC=96=B5=20=C2=B7=20=EA=B4=80=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9E=A5=EB=B6=80=20=EC=82=AC=EB=B3=B8)?= =?UTF-8?q?=20=C2=B7=20follow=20=EC=9E=90=EB=A6=AC=20=EB=A7=9E=EC=B6=A4=20?= =?UTF-8?q?=C2=B7=20cross=20=EB=A7=A5=EB=9D=BD=20=3D=20logic/design=5Fpipe?= =?UTF-8?q?line=5Fstage=5Finputs.ts(=ED=8E=98=EC=9D=B4=EC=A7=80=20693=20?= =?UTF-8?q?=E2=86=92=20659=20=C2=B7=20=EB=B9=BC=EA=B8=B0=EB=A7=8C)=20?= =?UTF-8?q?=C2=B7=20=ED=8C=90=20=3D=20=EB=8B=A8=EA=B3=84=20=EB=8B=A4?= =?UTF-8?q?=EC=84=AF=20=EC=A4=84=20=EC=9E=A0=EA=B8=88(=EC=A7=9D=20?= =?UTF-8?q?=EB=AA=A8=EC=9C=BC=EB=8A=94=20=EC=A4=91=20=C2=B7=20B-n)=20?= =?UTF-8?q?=C2=B7=20F9=20=3D=20=ED=8F=BC=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EB=B0=94=EB=80=9C=200?= =?UTF-8?q?=20=C2=B7=20=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=A6=9D=5Fsub2=20?= =?UTF-8?q?=3D=20=EC=B4=88=EC=95=88=20=EB=8B=A4=EC=84=AF=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EB=84=B7=200=20=EC=B0=A8=20=E2=80=94=20=EC=9A=94=EA=B5=AC=2032?= =?UTF-8?q?0-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_base.ts | 7 +- A00_Common/design/design_pipeline_page.ts | 46 +-- A00_Common/design/logic/design_calc_node.ts | 72 ++-- .../design/logic/design_calc_node_stages.ts | 194 ++++++++++ .../logic/design_pipeline_stage_inputs.ts | 64 ++++ B01_Dashboard/B01_Dashboard_UI_CalcSites.ts | 2 +- B06_Section/B06_Section_Route_Carry_Patch.ts | 2 +- common_util/common_util_calc_runner.py | 8 +- common_util/common_util_calc_sites.py | 33 +- resources/tester/test_309_2_calc_sites.py | 6 +- .../tester/test_309_2_calc_sites_browser.py | 7 +- resources/tester/test_472_3_calc_registry.py | 18 +- .../tester/test_511_b1_calc_node_stages.py | 359 ++++++++++++++++++ ui_template/ui_template_locale_m1.ts | 25 +- 14 files changed, 745 insertions(+), 98 deletions(-) create mode 100644 A00_Common/design/logic/design_calc_node_stages.ts create mode 100644 A00_Common/design/logic/design_pipeline_stage_inputs.ts create mode 100644 resources/tester/test_511_b1_calc_node_stages.py diff --git a/A00_Common/design/design_pipeline_base.ts b/A00_Common/design/design_pipeline_base.ts index bcf843930..0a67c0a40 100644 --- a/A00_Common/design/design_pipeline_base.ts +++ b/A00_Common/design/design_pipeline_base.ts @@ -25,11 +25,16 @@ export function plantedBase( source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null }, inputs: Pick, types: ReadonlyArray, + /** 관 세션 id — 없으면 세션 장부(`withPipeIds`) · 서버 Node 는 장부 사본(511-B). */ + pipeId?: (chainageM: number) => string, ): AlignmentBase | null { const detail = source.detail ?? null; const stored = detail && readAlignment(detail.longitudinal); if (!detail || !stored) return source.base; - const pipes = withPipeIds((inputs.pipes ?? []) as Array<{ chainage_m: number }>); + const list = (inputs.pipes ?? []) as Array<{ chainage_m: number }>; + const pipes = pipeId + ? list.map((pipe) => ({ ...pipe, id: pipeId(pipe.chainage_m) })) + : withPipeIds(list); const structures = (inputs.structures ?? []) as Parameters[1]; const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 return listedBase(toAlignmentBase(stored), detail, planted); diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index ac82d8e1a..7812afd9b 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -40,9 +40,9 @@ import type { import { useDesignStore, type DesignStoreValues } from "./design_store"; import { followStructures, - placedOnLine as placed, type FollowingStructure, } from "../../common_util/common_util_route_follow"; +import { contextCross, placedFollowInputs } from "./logic/design_pipeline_stage_inputs"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { @@ -91,15 +91,10 @@ import { routeRange, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; import { - crossWithContext, draftSectionDetail, shapeStatus, - type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; -import { - buildStations, - SECTION_STATION_INTERVAL_M, -} from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server"; import type { PlanPipe, @@ -291,29 +286,8 @@ function createPagePipeline( const { pipes, structures, plusStations } = inputsNow(); return { alignment: kept, pipes, structures, plusStations }; } - const line = ctx.before.route?.line ?? savedLine() ?? []; - // 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410) - const frame = (list: unknown, saved: unknown) => - !ctx.before.route && ctx.inputs.route && list !== saved - ? (ctx.out.route?.line ?? line) - : line; - const { pipes, structures } = ctx.inputs; - // 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410) - const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[]; - const inputs = { - ...ctx.inputs, - pipes: placed(pipes, frame(pipes, savedPipes), [ - ...(savedPipes as FollowingStructure[]), - ...followed, - ]), - structures: placed( - structures, - frame(structures, savedStructures), - [], - SECTION_STATION_INTERVAL_M, - ), - }; - return followStage({ ...ctx, inputs }); + const saved = { pipes: savedPipes, structures: savedStructures }; + return followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) }); }, stations: createStationsStage({ intervalM: options.stationIntervalM, @@ -331,19 +305,11 @@ function createPagePipeline( // 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침) cross: (ctx) => { const stations = ctx.out.ground?.stations ?? []; + const status = statusOf(ctx.inputs.pipes, ctx.inputs.structures); const engine = () => crossStage.stage({ - ...ctx, + ...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null), prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev, - inputs: { - ...ctx.inputs, - cross: crossWithContext( - ctx.inputs.cross as CrossInput | null, - stations, - statusOf(ctx.inputs.pipes, ctx.inputs.structures), - options.bermAt?.(ctx.inputs.structures) ?? null, - ), - }, }); const { detail = null, routeId } = savedSource(); const saved = diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index de85bb8ab..ca8f19746 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -14,12 +14,13 @@ * cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) · * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } · * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } · - * rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) } } + * rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) · + * stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). - * — 시험 길 id(`design_calc_node_trial.ts`)도 돌지만 `available` 에는 없음. - * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error }) + * — 시험 길 id(`design_calc_node_trial.ts`) · 단계 id(`stage.*`)도 돌지만 `available` 에는 없음. + * 끝 코드: 0 성공 / 1 셈 실패 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error }) * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; @@ -42,11 +43,12 @@ import { } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; +import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages"; import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs { +interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -134,8 +136,8 @@ function crossOptions( return options as unknown as CrossDesignOptions; } -/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */ -function runDesignCalc(run: readonly string[], inputs: CalcInputs) { +/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */ +async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const memo = new Map(); const sections = inputs.detail?.cross_sections ?? []; const context = inputs.context ?? {}; @@ -145,6 +147,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) ...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B) ...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A) + ...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); @@ -229,36 +232,43 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const unknown = run.filter((id) => !(id in RUNNERS)); const results: Record = {}; - if (!unknown.length) for (const id of run) results[id] = need(id); - const available = Object.keys(RUNNERS).filter((id) => !TRIAL_IDS.includes(id)); + if (!unknown.length) for (const id of run) results[id] = await need(id); + const outside = [...TRIAL_IDS, ...STAGE_IDS]; + const available = Object.keys(RUNNERS).filter((id) => !outside.includes(id)); return { results, unknown, available }; } type CalcRequest = { run?: string[]; inputs?: CalcInputs }; -/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 한 줄 실패는 그 줄 { error }(프로세스는 삶). */ +/** 한 줄 요청 → 한 줄 답 · 실패는 그 줄 { error }(프로세스는 삶). */ +async function answerLine(line: string): Promise { + try { + const request = JSON.parse(line) as CalcRequest; + const t0 = performance.now(); + const { results, unknown } = await runDesignCalc(request.run ?? [], request.inputs ?? {}); + const ms = Math.round((performance.now() - t0) * 100) / 100; + return unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 받은 차례대로(단계 id 는 기다림). */ function serve(): void { let buffer = ""; + let queue: Promise = Promise.resolve(); process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk: string) => { buffer += chunk; for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) { const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); - let answer: unknown; - try { - const request = JSON.parse(line) as CalcRequest; - const t0 = performance.now(); - const { results, unknown } = runDesignCalc(request.run ?? [], request.inputs ?? {}); - const ms = Math.round((performance.now() - t0) * 100) / 100; - answer = unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms }; - } catch (error) { - answer = { error: error instanceof Error ? error.message : String(error) }; - } - process.stdout.write(`${JSON.stringify(answer)}\n`); + queue = queue.then(async () => { + process.stdout.write(`${JSON.stringify(await answerLine(line))}\n`); + }); } }); - process.stdin.on("end", () => process.exit(0)); + process.stdin.on("end", () => void queue.then(() => process.exit(0))); } const [inputPath, outputPath] = process.argv.slice(2); @@ -268,10 +278,18 @@ else if (!inputPath || !outputPath) { process.exit(2); } else { const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest; - 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 })); + void runDesignCalc(request.run ?? [], request.inputs ?? {}).then( + ({ results, unknown, available }) => { + if (unknown.length) { + console.error(`모르는 id: ${unknown.join(", ")}`); + process.exit(4); + } + writeFileSync(outputPath, JSON.stringify({ results, available })); + process.exit(0); // 묶음 타이머를 기다리지 않음 + }, + (error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }, + ); } diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts new file mode 100644 index 000000000..235cd3c30 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -0,0 +1,194 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_stages.ts + * 계산 Node 입구(`design_calc_node.ts`)의 단계 몫(PLAN 511-B B-1) — 페이지 묶음 단계(노선 · 따라감 · 측점 · + * 종단 · 횡단)를 서버 Node 가 **같은 단계 함수 · 같은 붙임**으로 셈. 한 요청 = 묶음 한 바퀴(앞 단계도 다시 셈) · + * id 여럿이면 그 바퀴 하나에서 꺼냄. 등록부 `node:` 밖(파이썬 `STAGE_IDS`) — 서버 정본 길은 B-2 ~ B-5 몫. + * + * 입력 stages { plan: 저장 노선(planned · curves · min_radius_m · station_snap · station_snap_step) · + * detail: 저장 상세(종단 기준 · 저장 PVI) · types: 구조물 정의 전부 · saved_pipes · saved_structures · + * saved_edits(노선 초안 없을 때 계획고 · 없으면 input.alignment) · saved_plus(추가 측점 정본 좌표) · + * before_draft(노선 초안 전 관 · 구조물 초안 · null 칸 = 저장분 · 없으면 input) · + * pipe_ids(관 세션 장부 `pipe-ids` 사본 · 종단 심는 측점 id · 없으면 새 차례) · + * interval_m · grid: { path: `dtm_grid_file` 이진 · design: las | sheet } · + * input: 묶음 입력(`PipelineInputs` · pipes · structures null = 저장분) } + * 출력 stage.<단계> = 그 단계 출력(JSON 꼴 · `plain`) + * 지반 = DTM 격자만(경로 · 수정 시각 기억) · 격자 없음 · 서버 표본 길 = 실패(DTM 아닌 지표면은 파이썬 길). + * 붙임 = 페이지 `design_pipeline_page.ts` 와 같음 — 다른 점: 저장 노선 횡단도 엔진(페이지는 다시 셈 창구) · + * 소단 = 브라우저 값(계산 자리 대조 없음) · 되돌리기 사진 · 가게 메아리 없음. + * ========================================================================== */ + +import { readFileSync, statSync } from "node:fs"; +import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; +import type { FollowingStructure } from "@util/common_util_route_follow"; +import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station"; +import { parseDtmGrid, type DtmGrid } from "@util/common_util_surface_sampler"; +import type { RoutePlanResponse } from "../../../B05_Profile/B05_Profile_Api_Replan"; +import { + createFollowStage, + createPlanlineStage, + profileStage, +} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +import { shapeStatus } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { + createGroundStage, + createRouteStage, +} from "../../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; +import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { + emptyEdits, + toAlignmentBase, + type AlignmentEdits, +} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; +import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage"; +import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch"; +import { plantedBase } from "../design_pipeline_base"; +import { createDesignPipeline } from "./design_pipeline"; +import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs"; +import { createStationsStage, snapOptions } from "./design_pipeline_stations"; +import type { PipelineInputs, StageName, Vertex } from "./design_pipeline_types"; +import { plain } from "./design_calc_node_trial"; + +export interface StagesInputs { + stages?: { + plan: Pick< + RoutePlanResponse, + "planned" | "curves" | "min_radius_m" | "station_snap" | "station_snap_step" + >; + detail?: SectionDetailResponse | null; + types: PlanType[]; + saved_pipes: PlanPipe[]; + saved_structures: PlanStructure[]; + saved_edits?: AlignmentEdits | null; + saved_plus?: FollowingStructure[] | null; + before_draft?: { pipes: unknown; structures: unknown } | null; + pipe_ids?: Parameters[0]; + interval_m?: number; + grid?: { path: string; design: "las" | "sheet" } | null; + input: Partial; + }; +} + +/** 묶음 출력으로 내는 단계 다섯(판 줄과 같은 이름 · ground · planline 은 안에서만). */ +export const STAGE_NAMES = ["route", "follow", "stations", "profile", "cross"] as const; +export const STAGE_IDS = STAGE_NAMES.map((name) => `stage.${name}`); + +let gridMemo: { path: string; mtime: number; grid: DtmGrid } | null = null; + +/** DTM 격자 이진 — 같은 경로 · 같은 수정 시각이면 앞에 읽은 것(늘 떠 있는 Node). */ +function gridAt(path: string): DtmGrid { + const mtime = statSync(path).mtimeMs; + if (gridMemo?.path !== path || gridMemo.mtime !== mtime) { + const bytes = readFileSync(path); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + gridMemo = { path, mtime, grid: parseDtmGrid(buffer as ArrayBuffer) }; + } + return gridMemo.grid; +} + +async function runStages( + stages: NonNullable, +): Promise> { + const { plan, detail = null, types } = stages; + const savedPipes = stages.saved_pipes; + const savedStructures = stages.saved_structures; + const enabled = types.filter((type) => (type as { enabled?: boolean }).enabled !== false); + const grid = stages.grid ? { grid: gridAt(stages.grid.path), design: stages.grid.design } : null; + const savedLine = (): Vertex[] => plan.planned.map(([x, y]): Vertex => [x, y]); + const savedCurves = () => + (plan.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })); + const inputs: PipelineInputs = { + route: stages.input.route ?? null, + alignment: stages.input.alignment ?? null, + cross: stages.input.cross ?? null, + pipes: stages.input.pipes ?? savedPipes, // 저장분 = 같은 객체(자리 맞춤이 초안과 가름) + structures: stages.input.structures ?? savedStructures, + plusStations: stages.input.plusStations ?? null, + }; + const stored = detail && readAlignment(detail.longitudinal); + const book = pipeBook(stages.pipe_ids ?? null); + // 따라감 기준(페이지 `beforeDraft ?? 가게`) — 초안 전 관 · 구조물(null = 저장분) + const before = stages.before_draft ?? stages.input; + const followStage = createFollowStage({ + savedLine, + savedPlus: () => stages.saved_plus ?? [], + saved: () => ({ + pipes: before.pipes ?? savedPipes, + structures: before.structures ?? savedStructures, + alignment: stages.saved_edits ?? inputs.alignment ?? emptyEdits(), + }), + stationsAt: (route, patch, at) => + buildStations( + route.line, + route.curves, + (patch.pipes ?? at.pipes ?? []) as PlanPipe[], + (patch.structures ?? at.structures ?? []) as PlanStructure[], + { intervalM: stages.interval_m, types: enabled, ...snapOptions(at.route, plan) }, + ).map((station) => station.chainageM), + }); + const crossStage = createCrossStage({ grid: () => grid }); + const pipe = createDesignPipeline({ + stages: { + route: createRouteStage({ + minRadiusM: () => plan.min_radius_m ?? 0, + savedLine: () => ({ line: savedLine(), curves: savedCurves() }), + }), + follow: (ctx) => + followStage({ + ...ctx, + inputs: placedFollowInputs(ctx, savedLine(), { + pipes: savedPipes, + structures: savedStructures, + }), + }), + stations: createStationsStage({ + intervalM: stages.interval_m, + types: () => enabled, + saved: () => plan, + }), + ground: createGroundStage({ + grid: () => grid, + remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")), + }), + planline: createPlanlineStage({ + savedBase: (at) => plantedBase({ base: null, detail }, at, types, book.at), + savedLine, + storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null), + }), + profile: profileStage, + cross: (ctx) => { + const status = shapeStatus( + ctx.inputs.pipes ?? savedPipes, + ctx.inputs.structures ?? savedStructures, + types, + ); + const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : []; + const spans = bermSpansFromStructures(list); + return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at))); + }, + }, + }); + try { + const record = await pipe.start(inputs); + await pipe.settled(); + if (record.outcome !== "done") throw new Error(`단계 묶음 ${record.outcome}`); + return Object.fromEntries( + STAGE_NAMES.map((name) => [name, plain(pipe.output(name))]), + ) as Record; + } finally { + pipe.dispose(); + } +} + +/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */ +export function stageRunners(inputs: StagesInputs): Record Promise> { + let run: Promise> | null = null; + const once = () => { + if (!inputs.stages) throw new Error("단계 입력(stages) 없음"); + return (run ??= runStages(inputs.stages)); + }; + return Object.fromEntries( + STAGE_NAMES.map((name) => [`stage.${name}`, () => once().then((out) => out[name])]), + ); +} diff --git a/A00_Common/design/logic/design_pipeline_stage_inputs.ts b/A00_Common/design/logic/design_pipeline_stage_inputs.ts new file mode 100644 index 000000000..695d67fcc --- /dev/null +++ b/A00_Common/design/logic/design_pipeline_stage_inputs.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * A00_Common/design/logic/design_pipeline_stage_inputs.ts + * 페이지 묶음 follow · cross 단계 입력 맞춤(PLAN 511-B B-1) — 페이지(`design_pipeline_page.ts`)와 서버 Node + * 단계 입구(`design_calc_node_stages.ts`)가 같은 함수로 단계에 넣음. 화면 · 가게 · 세션 안 씀. + * ========================================================================== */ + +import { + placedOnLine as placed, + type FollowingStructure, +} from "../../../common_util/common_util_route_follow"; +import { + crossWithContext, + type BermAt, + type CrossInput, + type ShapeStatus, +} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { SECTION_STATION_INTERVAL_M } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import type { PipelineInputs, StageContext, Vertex } from "./design_pipeline_types"; + +/** follow 입력 관 · 구조물 자리 — 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 + * 앞 노선 위(410). 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 초안 값(저장분과 다른 객체) = 이미 초안 + * 노선 자리(앞 따라감이 씀). */ +export function placedFollowInputs( + ctx: StageContext<"follow">, + savedLine: Vertex[] | null, + saved: { pipes: unknown; structures: unknown }, +): PipelineInputs { + const line = ctx.before.route?.line ?? savedLine ?? []; + const frame = (list: unknown, stored: unknown) => + !ctx.before.route && ctx.inputs.route && list !== stored ? (ctx.out.route?.line ?? line) : line; + const { pipes, structures } = ctx.inputs; + const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[]; + return { + ...ctx.inputs, + pipes: placed(pipes, frame(pipes, saved.pipes), [ + ...(saved.pipes as FollowingStructure[]), + ...followed, + ]), + structures: placed( + structures, + frame(structures, saved.structures), + [], + SECTION_STATION_INTERVAL_M, + ), + }; +} + +/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음(365-5 · 457). */ +export const contextCross = ( + ctx: StageContext<"cross">, + status: ShapeStatus | null, + bermAt: BermAt | null, +): StageContext<"cross"> => ({ + ...ctx, + inputs: { + ...ctx.inputs, + cross: crossWithContext( + ctx.inputs.cross as CrossInput | null, + ctx.out.ground?.stations ?? [], + status, + bermAt, + ), + }, +}); diff --git a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts index e73d2d505..677a9a878 100644 --- a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts +++ b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B01_Dashboard_UI_CalcSites.ts * 「계산 자리」 창(PLAN 504-2 · 504-3 · 요구 309-2 · 312) — 시스템 관리자만(카드 = `settingLinks` · 쓰기 = 서버 - * `require_system_admin`). 묶음 8 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중 + * `require_system_admin`). 묶음 12 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중 * 브라우저 셈」 · 「[저장] 서버 정본」 늘 켬 · 잠금). 묶음 머리 체크 = 그 묶음 전부. * 바꿀 수 있는 줄 = 서버 판 `groups[].switchable`(판 열쇠 = 묶음) · 나머지는 잠긴 체크 + 까닭 글. * 고침은 창 안 사본에만 · [저장] 때만 바뀐 줄을 PUT(선례 `app_settings.ts`). diff --git a/B06_Section/B06_Section_Route_Carry_Patch.ts b/B06_Section/B06_Section_Route_Carry_Patch.ts index 643eeffa0..e365281ef 100644 --- a/B06_Section/B06_Section_Route_Carry_Patch.ts +++ b/B06_Section/B06_Section_Route_Carry_Patch.ts @@ -308,7 +308,7 @@ export interface RouteCarryInput { } /** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */ -function pipeBook(book: RouteCarryInput["pipe_ids"]) { +export function pipeBook(book: RouteCarryInput["pipe_ids"]) { const ids = [...(book?.ids ?? [])]; let next = book?.next ?? 1; const near = (at: number): string | null => { diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index e4450fc7f..da8b2b5a7 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -6,7 +6,7 @@ 입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`). `run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) — 늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 · -시험 길 `TRIAL_IDS` 도 받음. +시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) 도 받음. """ from __future__ import annotations @@ -59,6 +59,10 @@ TRIAL_IDS = frozenset( "footprint.plan", } ) +# 묶음 단계(`design_calc_node_stages.ts` `STAGE_IDS` 와 같음 · 511-B) — 한 요청 = 묶음 한 바퀴 +STAGE_IDS = frozenset( + {"stage.route", "stage.follow", "stage.stations", "stage.profile", "stage.cross"} +) SERVE_TIMEOUT_S = 60 @@ -129,7 +133,7 @@ atexit.register(_SERVED.stop) def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: """창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None.""" - unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS] + unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS] if unknown: raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}") t0 = time.perf_counter() diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index 4deec5a27..789e460df 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -4,7 +4,7 @@ both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길 (G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구). 판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. -묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. +묶음 12 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. """ from __future__ import annotations @@ -30,21 +30,38 @@ GROUPS: tuple[dict[str, Any], ...] = ( "why": "quantity", }, {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, + # 묶음 단계 다섯(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-2 ~ B-5 끝난 줄부터 엶 + {"key": "route", "nos": ["R1", "R3"], "switchable": False, "fixed": "both", "why": "pair_B5"}, { - "key": "stages", - "nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"] - + ["P8", "C1", "C2", "C3", "C5", "C6", "F1", "F2", "F3", "F4"], + "key": "follow", + "nos": ["P5", "P6", "F1", "F2"], "switchable": False, "fixed": "both", - "why": "pair_later", + "why": "pair_B5", }, { - "key": "structure_pair", - "nos": ["F5", "F6", "F9"], + "key": "stations", + "nos": ["R5", "R6", "R7", "F3"], "switchable": False, "fixed": "both", - "why": "pair", + "why": "pair_B2", }, + { + "key": "profile", + "nos": ["P1", "P2", "P3", "P4", "P8"], + "switchable": False, + "fixed": "both", + "why": "pair_B3", + }, + { + "key": "cross", + "nos": ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], + "switchable": False, + "fixed": "both", + "why": "pair_B4", + }, + # 폼 기본값 도구(B04 · B08 파이썬 · 고를 것 없음) + {"key": "form_default", "nos": ["F9"], "switchable": False, "fixed": "both", "why": None}, { "key": "fixed_server", "nos": ["E2", "G3", "D1", "D2", "N1"], diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index fdb1f62ce..1e9b60dcd 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -1,7 +1,7 @@ """PLAN 504-2 (요구 309-2 · 310) — 계산 자리 판 · 시스템 관리자 창구 · G1 · D3 연결. 못박는 것 - - 묶음 8 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. + - 묶음 12 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. - 커밋한 판 = 기본값(전부 both = 등록부 그대로) · 판 없음 · 깨짐 = 기본값. - GET = 로그인 · PUT = 시스템 관리자만(ADMIN · USER 403) · 잠금 · 모르는 줄 · 값 = 400 · 바뀐 줄마다 시스템 로그 CALC_SITE_CHANGE 한 줄 · 같은 값 = 판 · 로그 그대로. @@ -56,7 +56,7 @@ def test_묶음_여덟_줄_등록부_전부(): sites = _registry_sites() assert len(sites) == 45 nos = [no for group in calc_sites.GROUPS for no in group["nos"]] - assert len(calc_sites.GROUPS) == 8 + assert len(calc_sites.GROUPS) == 12 # 511-B 단계 다섯 · F9 폼 기본값 assert len(nos) == len(set(nos)) and set(nos) == set(sites) assert calc_sites.SWITCHABLE == { "ground_sample", @@ -155,7 +155,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client): def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" - for bad in ({"stages": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + for bad in ({"cross": "server"}, {"fixed_server": "both"}, {"nope": "server"}): assert http.put(url, json={"sites": bad}).status_code == 400, bad assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 assert log == [] diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index 607436f38..1b257bc15 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -143,12 +143,15 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): rows = page.eval_on_selector_all( ".b01-dashboard__calc-sites tbody tr", "(trs) => trs.map((tr) => ({ head: tr.classList.contains('b01-dashboard__calc-group')," - " name: tr.cells[0].textContent.trim()," + " name: tr.cells[0].textContent.trim(), why: tr.cells[3].textContent.trim()," " boxes: [...tr.querySelectorAll('input')].map((i) => [i.checked, i.disabled]) }))", ) heads = [row for row in rows if row["head"]] calcs = [row for row in rows if not row["head"]] - assert len(heads) == 8 and len(calcs) == 45 + assert len(heads) == 12 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 폼 기본값 + whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} + for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3, "C1": 4, "F6": 4}.items(): + assert whys[no] == f"짝 모으는 중 · B-{n}", no assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 4572d363f..c1e3067ba 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -106,7 +106,23 @@ GEOMETRY_PULLS = ( # 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀 "B06_Section/B06_Section_Route_Carry_Patch.ts", ) -KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) +# 묶음 단계 입구(511-B B-1 · `design_calc_node_stages.ts`) — planline 저장 기준 +# (`plantedBase`)이 종단도 측점 표 · 관 세션 id 파일을 끎(모듈 맨 위 DOM · 세션 없음) +# — B-3 이 logic 으로 옮기면 빈 목록. +STAGE_PULLS = ( + "A00_Common/b_page_state.ts", + "A00_Common/design/design_pipe_ids.ts", + "A00_Common/design/design_pipeline_base.ts", + "A00_Common/design/design_profile_base.ts", + "A00_Common/design/design_selection.ts", + "A00_Common/design/design_store.ts", + "A00_Common/design/design_structure_status.ts", + "B05_Profile/B05_Profile_UI_IrregularStations.ts", + "B05_Profile/B05_Profile_UI_Profile_Layout.ts", + "B05_Profile/B05_Profile_UI_Profile_Table.ts", + "B06_Section/B06_Section_UI_Section_Common.ts", +) +KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) | set(STAGE_PULLS) def _node(*args: str, timeout: int = 120) -> str: diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py new file mode 100644 index 000000000..619e3556c --- /dev/null +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -0,0 +1,359 @@ +"""PLAN 511-B B-1 (요구 320-4) — 묶음 단계 Node 입구 · 판 다섯 줄. + +못박는 것 + - 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 = + 파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음. + - 판 = 단계 다섯 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」) · F9 = 폼 기본값 · + 커밋한 판 그대로. + - 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎 + (측점 = 저장 상세 측점 0.01 m). + - 화면(검증_sub2 · 이 폴더 서버 · Playwright) — 페이지 묶음 입력 · 저장 기준을 떠서 Node + 단계에 넣으면 단계 출력 = 페이지 단계 출력 0 차 — 노선 초안 둔 채 다시 열기 = 다섯 · + 저장 노선 = 넷(횡단은 페이지가 다시 셈 창구 = 파이썬 저장 설계 · B-4 몫 · 열쇠만 같음). + 저장 안 함 · 세션만. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b1_calc_node_stages.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 + +from common_util import common_util_calc_runner as runner # noqa: E402 +from common_util import common_util_calc_sites as calc_sites # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" +STAGES_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_stages.ts" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +CAPTURE = ROOT / "tmp" / "511b1_capture.json" +STAGES = ("route", "follow", "stations", "profile", "cross") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _stages(stages: dict) -> dict: + """다섯 단계를 한 요청으로(늘 떠 있는 Node) → 단계 이름 → 출력.""" + answer = runner.run_calc_served([f"stage.{n}" for n in STAGES], {"stages": stages}) + assert answer is not None and "error" not in answer, answer + return {n: answer["results"][f"stage.{n}"] for n in STAGES} + + +def test_단계_id_표_같음_등록부_밖(): + names = re.search(r"STAGE_NAMES = \[([^\]]+)\]", STAGES_TS.read_text(encoding="utf-8")) + ts = {f"stage.{n}" for n in re.findall(r'"(\w+)"', names.group(1))} + assert ts == runner.STAGE_IDS == {f"stage.{n}" for n in STAGES} + out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) + assert set(out["available"]) == runner.NODE_IDS # 단계 id 는 등록부 밖 + with pytest.raises(ValueError, match="stage.ground"): + runner.run_calc_served(["stage.ground"], {}) + bad = runner.run_calc_served(["stage.route"], {}) + assert "단계 입력" in bad["error"] # 입력 없음 = 그 줄 오류(프로세스는 삶) + + +def test_판_다섯_줄_잠금_기본값_그대로(): + by_key = {g["key"]: g for g in calc_sites.GROUPS} + expected = { + "route": (["R1", "R3"], "pair_B5"), + "follow": (["P5", "P6", "F1", "F2"], "pair_B5"), + "stations": (["R5", "R6", "R7", "F3"], "pair_B2"), + "profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"), + "cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"), + } + for key, (nos, why) in expected.items(): + group = by_key[key] + assert group["nos"] == nos and group["why"] == why, key + assert group["switchable"] is False and group["fixed"] == "both", key + assert by_key["form_default"]["nos"] == ["F9"] and by_key["form_default"]["fixed"] == "both" + assert "stages" not in by_key and "structure_pair" not in by_key + assert set(calc_sites.SWITCHABLE) == { + "ground_sample", + "corridor_mesh", + "quantity", + "carry", + "fixed_browser", + } + committed = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["git", "show", "HEAD:resources/master_template/calc_sites.json"], + cwd=ROOT, + capture_output=True, + check=True, + ).stdout + assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n") + locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8") + for n in (2, 3, 4, 5): + assert f'B01_CalcSites_Why_pair_B{n}: ["짝 모으는 중 · B-{n}"' in locale + + +def test_페이지_빼기만_같은_맞춤_함수(): + page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + assert len(page.splitlines()) <= 693 # 늘리지 않음(B-1 전 693) + assert ( + "placedFollowInputs(ctx, savedLine(), saved)" in page and "contextCross(ctx, status" in page + ) + assert "placed(" not in page and "crossWithContext(" not in page + stages = STAGES_TS.read_text(encoding="utf-8") + assert "placedFollowInputs(ctx" in stages and "contextCross(ctx" in stages + + +def test_고정_자료_Node_단계_저장_노선_한_바퀴(tmp_path): + from common_util.common_util_surface_sampler import dtm_grid_bytes + + grid = tmp_path / "grid.bin" + grid.write_bytes( + dtm_grid_bytes(FIXTURE / "project" / "B04_PreProcess/models/dtm_classification_smooth.npz") + ) + detail = _fixture("detail.json") + stages = { + "plan": _fixture("plan.json"), + "detail": detail, + "types": _fixture("structure_types.json")["types"], + "saved_pipes": [ + {k: v for k, v in p.items() if k != "lonlat"} + for p in _fixture("pipes.json")["pipe_points"] + ], + "saved_structures": _fixture("structures.json")["structures"], + "interval_m": 20, + "grid": {"path": str(grid), "design": "las"}, + "input": {"plusStations": _fixture("plus_stations.json")["chainages"]}, + } + out = _stages(stages) + assert out["route"]["line"] == [[x, y] for x, y in stages["plan"]["planned"]] + # 끝점 = 노선 길이(파이썬 저장 상세는 0.6 mm 다름 · 차례 뒤바뀜 = B-2 몫) → 0.01 m · 차례 없이 + saved = sorted(round(s["chainage_m"], 2) for s in detail["cross_sections"]) + assert [round(s["chainageM"], 2) for s in out["stations"]] == saved + assert len(out["profile"]["pvi"]) >= 2 and out["cross"] == {"byKey": {}} # 횡단 입력 없음 + + +# ── 화면 — 페이지 단계 출력 대 Node 단계 출력(검증_sub2) ────────────────────────────── + +API, BASE = own_ports() + + +def _started(base: str) -> str | None: + try: + with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: + return json.loads(res.read()).get("started_at") + except Exception: + return None + + +_UP = _started(BASE) if BASE else None +screen = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +# 페이지 묶음 입력(`inputsNow` 꼴 · 가게 null = 저장분) · 저장 기준 · 다섯 단계 출력을 뜸 +JS_CAPTURE = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => + x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const data = await mod(/design_page_data\.ts/); + const routeId = data.pageRouteId(pid); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const types = await (await mod(/B05_Profile_Api_Structures\.ts/)).fetchStructureTypes(true); + const list = await data.pageStructures(pid); + const pipes = await data.pagePipes(pid); + const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); + const store = (await mod(/design_store\.ts/)).useDesignStore(pid, null); + const edit = await mod(/B05_Profile_UI_Profile_Edit\.ts/); + const prof = await mod(/B05_Profile_UI_Profile_Data\.ts/); + const plus = await mod(/B05_Profile_Api_PlusStations\.ts/); + const state = await mod(/b_page_state\.ts/); + const route = store.get('routeDraft'); + const savedEdits = edit.readAlignmentDraft(routeId) + ?? prof.readAlignment(detail.longitudinal)?.edits ?? null; + const terrain = await (await fetch(`/api/projects/${pid}/route/terrain-sources`, + { credentials: 'include' })).json(); + return plain({ + routeId, + terrain: { design: terrain.design, grid: terrain.grid }, + stages: { + plan, detail, types, + saved_pipes: (pipes?.pipe_points ?? []).map(({ lonlat: _l, ...p }) => p), + saved_structures: list?.structures ?? [], + saved_edits: savedEdits, + saved_plus: plus.savedPlusRows(pid), + interval_m: 20, + before_draft: route ? (state.readState('route-draft-hold', pid)?.facilities ?? null) : null, + pipe_ids: state.readState('pipe-ids', pid), + input: { + route, + alignment: route ? (edit.readAlignmentDraft(0) || savedEdits) : pipe.input('alignment'), + cross: pipe.input('cross'), + pipes: store.get('pipes'), + structures: store.get('structures'), + plusStations: plus.plusStationsOf(pid), + }, + }, + outputs: Object.fromEntries(['route', 'follow', 'stations', 'profile', 'cross'] + .map((n) => [n, pipe.output(n)])), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + }); +}""" + +# 노선 초안 — 가운데 교각점 하나 옮김(편집기 다리 · 세션 칸 route-draft) +JS_DRAFT = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => import(urls.find((n) => re.test(n))); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) + .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); + const nodes = flat.planned.map(([x, y]) => [x, y]); + const at = Math.floor(nodes.length / 2); + nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4]; + page.routeDraftBridge(pid, null).edited({ + nodes, + curveOn: flat.curveOn.map((on) => on !== false), + curveRadius: flat.curveRadius.map((r) => r ?? null), + curveLock: flat.curveLock ?? nodes.map(() => null), + curveArc: flat.curveArc ?? nodes.map(() => null), + stationSnap: plan.station_snap ?? false, + stationSnapStep: plan.station_snap_step ?? null, + }); + await page.reconcilePagePipeline(pid); + await page.pagePipeline(pid).settled(); + return at; +}""" + + +def _grid_path(terrain: dict) -> dict | None: + """페이지가 받은 격자와 같은 파일(`read_surface_grid` 고르기) — DB 읽기만.""" + info = terrain.get("grid") + if not info: + return None + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_surface_sampler import dtm_grid_file + from config.config_db import close_db_pool, init_db_pool + + async def lookup(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, PROJECT) + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT model_file_path FROM surface_models" + " WHERE id = %s AND project_id = %s", + (info["model_id"], PROJECT), + ) + row = await cursor.fetchone() + return stored, row[0] + finally: + await close_db_pool() + + stored, rel = asyncio.run(lookup()) + model = Path(resolve_stored_project_path(stored)) / rel + smooth = model.with_name(f"{model.stem}_smooth.npz") + npz = smooth if info["smooth"] and smooth.is_file() else model + return {"path": str(dtm_grid_file(npz)), "design": terrain.get("design") or "las"} + + +@pytest.fixture(scope="module") +def captured(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots["saved"] = page.evaluate(JS_CAPTURE) + page.evaluate(JS_DRAFT) + page.wait_for_timeout(3000) + page.reload(wait_until="domcontentloaded") # 초안 둔 채 다시 열기 = 처음부터 한 바퀴 + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots["draft"] = page.evaluate(JS_CAPTURE) + page.close() # 세션 초안은 창과 함께 버림(저장 안 함) + finally: + chrome.close() + CAPTURE.write_text(json.dumps(shots, ensure_ascii=False), encoding="utf-8") + return shots + + +def _diff(a, b, path="") -> str | None: + """첫 다른 자리(0 차 — 수도 정확히 같아야 함).""" + if type(a) is not type(b) and not (isinstance(a, (int, float)) and isinstance(b, (int, float))): + return f"{path}: {type(a).__name__} ≠ {type(b).__name__}" + if isinstance(a, dict): + for k in sorted(set(a) | set(b)): + if k not in a or k not in b: + return f"{path}.{k}: 한쪽만" + hit = _diff(a[k], b[k], f"{path}.{k}") + if hit: + return hit + return None + if isinstance(a, list): + if len(a) != len(b): + return f"{path}: 길이 {len(a)} ≠ {len(b)}" + for i, (x, y) in enumerate(zip(a, b)): + hit = _diff(x, y, f"{path}[{i}]") + if hit: + return hit + return None + return None if a == b else f"{path}: {a!r} ≠ {b!r}" + + +@screen +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_화면_Node_단계_출력_페이지와_0차(captured, case): + shot = captured[case] + assert shot["runs"] == ["done"], shot["runs"] + assert (shot["stages"]["input"]["route"] is not None) == (case == "draft") + stages = {**shot["stages"], "grid": _grid_path(shot["terrain"])} + out = _stages(stages) + # 저장 노선 횡단 = 페이지는 다시 셈 창구(저장 상세 설계 · 파이썬 짝 · 칸 더 많음) + # — 엔진 대조는 B-4 몫 + names = STAGES if case == "draft" else STAGES[:-1] + for name in names: + hit = _diff(out[name], shot["outputs"][name], name) + assert hit is None, hit + assert len(out["stations"]) > 10 and out["cross"]["byKey"] + assert set(out["cross"]["byKey"]) == set(shot["outputs"]["cross"]["byKey"]) # 측점 열쇠는 같음 diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index bc6109fff..cb45089fc 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -91,11 +91,12 @@ export const ui_locales_m1 = { B01_CalcSites_corridor_mesh: ["3D 길 모형", "3D corridor mesh"], B01_CalcSites_quantity: ["토량 · 구조물 셈", "Earthwork · structure calc"], B01_CalcSites_carry: ["이월 · 다시 얹기", "Carry · rebase"], - B01_CalcSites_stages: [ - "노선 · 측점 · 종단 · 따라감 · 횡단 단계", - "Route · station · profile · follow · cross stages", - ], - B01_CalcSites_structure_pair: ["구조물 짝", "Structure pairs"], + B01_CalcSites_route: ["노선 단계", "Route stage"], + B01_CalcSites_follow: ["따라감 단계", "Follow stage"], + B01_CalcSites_stations: ["측점 단계", "Station stage"], + B01_CalcSites_profile: ["종단 단계", "Profile stage"], + B01_CalcSites_cross: ["횡단 단계", "Cross stage"], + B01_CalcSites_form_default: ["폼 기본값 도구", "Form default tool"], B01_CalcSites_fixed_server: ["고정 서버", "Fixed server"], B01_CalcSites_fixed_browser: ["고정 브라우저", "Fixed browser"], B01_CalcSites_Why_ground_sample: [ @@ -114,13 +115,13 @@ export const ui_locales_m1 = { "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", "Off = server calc after a pause · F10 = no browser call", ], - B01_CalcSites_Why_pair_later: [ - "파이썬 짝 섞임 · 짝 모으기(472-4) 뒤", - "Mixed Python pairs · after pair merge (472-4)", - ], - B01_CalcSites_Why_pair: [ - "파이썬 정본 짝 유지 · TS 는 미리보기", - "Python canon pair · TS = preview", + B01_CalcSites_Why_pair_B2: ["짝 모으는 중 · B-2", "Merging pairs · B-2"], + B01_CalcSites_Why_pair_B3: ["짝 모으는 중 · B-3", "Merging pairs · B-3"], + B01_CalcSites_Why_pair_B4: ["짝 모으는 중 · B-4", "Merging pairs · B-4"], + B01_CalcSites_Why_pair_B5: ["짝 모으는 중 · B-5", "Merging pairs · B-5"], + B01_CalcSites_Why_form_default: [ + "폼 기본값 도구 — 입력 칸 기본값 · 서버 다시 안 셈(B04 · B08 은 파이썬)", + "Form default tool — input default · server never recalcs (B04 · B08 = Python)", ], B01_CalcSites_Why_server: [ "서버 단독(GIS · 래스터 · 값어치)",