Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3
This commit is contained in:
@@ -25,11 +25,16 @@ export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
/** 관 세션 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<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<Parameters<typeof computeHaulPlan>[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<string, unknown>();
|
||||
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<string, unknown> = {};
|
||||
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<unknown> {
|
||||
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<void> = 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof pipeBook>[0];
|
||||
interval_m?: number;
|
||||
grid?: { path: string; design: "las" | "sheet" } | null;
|
||||
input: Partial<PipelineInputs>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력으로 내는 단계 다섯(판 줄과 같은 이름 · 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<StagesInputs["stages"]>,
|
||||
): Promise<Record<StageName, unknown>> {
|
||||
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<StageName, unknown>;
|
||||
} finally {
|
||||
pipe.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */
|
||||
export function stageRunners(inputs: StagesInputs): Record<string, () => Promise<unknown>> {
|
||||
let run: Promise<Record<StageName, unknown>> | 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])]),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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 ③ ④ 더
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]) # 측점 열쇠는 같음
|
||||
@@ -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 · 래스터 · 값어치)",
|
||||
|
||||
Reference in New Issue
Block a user