Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3

This commit is contained in:
2026-10-22 13:51:13 +09:00
14 changed files with 745 additions and 98 deletions
+6 -1
View File
@@ -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);
+6 -40
View File
@@ -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 =
+45 -27
View File
@@ -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 -1
View File
@@ -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`).
+1 -1
View File
@@ -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 -2
View File
@@ -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()
+25 -8
View File
@@ -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"],
+3 -3
View File
@@ -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 ③ ④ 더
+17 -1
View File
@@ -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"]) # 측점 열쇠는 같음
+13 -12
View File
@@ -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 · 래스터 · 값어치)",