Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_2
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
* 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
|
||||
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
|
||||
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
|
||||
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
|
||||
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
|
||||
*
|
||||
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
|
||||
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
|
||||
@@ -26,9 +32,17 @@ import {
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { clearState, stateKey } from "../b_page_state";
|
||||
import {
|
||||
carryAfterRouteSave,
|
||||
pendingRouteSave,
|
||||
rebuildAfterRouteSave,
|
||||
routeSaveSummary,
|
||||
saveRouteDraft,
|
||||
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
|
||||
|
||||
/** 저장 차례 — 이 순서 그대로 돈다. */
|
||||
/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"route",
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
@@ -48,7 +62,7 @@ export interface DesignSaveContext {
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
cross: (confirm: boolean) => Promise<void>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
@@ -87,12 +101,17 @@ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] })
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||
const draft = pendingAlignment(routeId);
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. 노선이 바뀌었으면
|
||||
* 옛 노선 칸(`from`)의 초안을 새 노선(`routeId`)에 — 측점이 없는 자리는 서버가 버리고 알림(213-2). */
|
||||
async function flushAlignmentDraft(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
from: number = routeId,
|
||||
): Promise<void> {
|
||||
const draft = pendingAlignment(from);
|
||||
if (!draft) return;
|
||||
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
|
||||
markAlignmentSaved(routeId);
|
||||
markAlignmentSaved(from);
|
||||
}
|
||||
|
||||
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
|
||||
@@ -104,9 +123,10 @@ const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
|
||||
|
||||
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross">,
|
||||
step: Exclude<DesignSaveStep, "cross" | "route">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
fromRouteId: number = routeId,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "pipes":
|
||||
@@ -114,10 +134,10 @@ function defaultStep(
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "alignment":
|
||||
return () => flushAlignmentDraft(projectId, routeId);
|
||||
return () => flushAlignmentDraft(projectId, routeId, fromRouteId);
|
||||
case "culvertOptions":
|
||||
return () =>
|
||||
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
|
||||
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
case "uphill":
|
||||
@@ -134,19 +154,34 @@ export async function saveDesign(
|
||||
options: { confirm?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const confirm = options.confirm ?? false;
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
const fromRouteId = ctx.routeId();
|
||||
if (fromRouteId === null) return false;
|
||||
let routeId = fromRouteId;
|
||||
/** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */
|
||||
let routeSaved: Awaited<ReturnType<typeof saveRouteDraft>> | null = null;
|
||||
showLoadingOverlay();
|
||||
saving.add(ctx.projectId);
|
||||
try {
|
||||
await ctx.prepare?.();
|
||||
for (const step of DESIGN_SAVE_ORDER) {
|
||||
if (step === "cross") {
|
||||
await ctx.cross(confirm);
|
||||
if (step === "route") {
|
||||
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
|
||||
if (!body) continue;
|
||||
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
|
||||
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
|
||||
continue;
|
||||
}
|
||||
const own = ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId);
|
||||
if (step === "cross") {
|
||||
if (!routeSaved) await ctx.cross(confirm);
|
||||
else {
|
||||
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
|
||||
showToast(routeSaveSummary(routeSaved, outside), "info");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
|
||||
const own = routeSaved ? undefined : ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
@@ -159,6 +194,14 @@ export async function saveDesign(
|
||||
}
|
||||
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
|
||||
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
|
||||
if (routeSaved) {
|
||||
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
|
||||
if (confirm)
|
||||
showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
|
||||
else await ctx.done?.(false);
|
||||
await rebuildAfterRouteSave(ctx.projectId);
|
||||
return true;
|
||||
}
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 계획노선 두 벌(예상노선·계획노선) 읽기와 노선 갈아 끼우기 요청.
|
||||
*
|
||||
* GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m)
|
||||
* POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
|
||||
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
*
|
||||
@@ -18,6 +18,7 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { heightAt } from "../common_util/common_util_surface_sampler";
|
||||
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||||
import type { RouteDraft } from "../A00_Common/design/design_pipeline_types";
|
||||
|
||||
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
|
||||
export type AnalysisTerrain = "las" | "sheet";
|
||||
@@ -114,7 +115,23 @@ export interface RouteReplanResponse {
|
||||
project_id: string;
|
||||
route_id: number | null;
|
||||
total_length_m: number | null;
|
||||
vertex_count: number;
|
||||
vertex_count?: number;
|
||||
/** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */
|
||||
pipes?: { kept: number; crossing: number; concave: number };
|
||||
structures?: { moved: number; far: number };
|
||||
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
|
||||
cross_dropped?: number;
|
||||
/** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */
|
||||
drainage_stale?: boolean;
|
||||
initial_refreshed?: boolean;
|
||||
}
|
||||
|
||||
/** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */
|
||||
export interface RouteSaveBody {
|
||||
draft: RouteDraft;
|
||||
initial?: unknown;
|
||||
balanced_profile?: boolean;
|
||||
as_initial?: boolean;
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
@@ -158,23 +175,18 @@ export interface RouteReplanVertex {
|
||||
radius_m?: number | null;
|
||||
}
|
||||
|
||||
/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다.
|
||||
/** 노선 초안을 정본으로 저장한다(346-3) — 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 옮김 →
|
||||
* 횡단 이월 → B06 확정까지 돈다. 실패(409)면 서버가 옛 노선으로 되살리고 오류 글을 낸다.
|
||||
*
|
||||
* 편집 세 가지가 모두 이 한 목록으로 나간다(2026-09-07 사용자 지시) —
|
||||
* **직선 삭제·추가**는 점을 빼거나 더하는 것, **곡선 삭제·추가**는 `curve` 를 끄고 켜는 것,
|
||||
* **반지름 변경**은 `radius_m` 을 주는 것. */
|
||||
* 편집 세 가지가 모두 초안 한 벌로 나간다(2026-09-07 사용자 지시) — **직선 삭제·추가**는 노드를 빼거나
|
||||
* 더하는 것, **곡선 삭제·추가**는 `curveOn` 을 끄고 켜는 것, **반지름 변경**은 `curveRadius`. */
|
||||
export async function replanRoute(
|
||||
projectId: string,
|
||||
vertices: Array<[number, number]> | RouteReplanVertex[],
|
||||
/** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */
|
||||
extra: Record<string, unknown> = {},
|
||||
body: RouteSaveBody,
|
||||
): Promise<RouteReplanResponse> {
|
||||
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
|
||||
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
|
||||
);
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan`,
|
||||
{ method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) },
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,105 +1,201 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Apply.ts
|
||||
* 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시.
|
||||
* 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰.
|
||||
*
|
||||
* 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면
|
||||
* 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이
|
||||
* 페이지를 새로고침 없이 다시 세운다(`onApplied`).
|
||||
*
|
||||
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
|
||||
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
|
||||
* 멈춘 것인지 도는 것인지 알 수 있게 한다.
|
||||
* · 페이지 [저장](`design_save`)이 맨 앞에서 `pendingRouteSave` 로 지금 노선 초안을 받아 `saveRouteDraft`
|
||||
* (`POST /route/replan` · draft = 계약 `RouteDraft`)로 보낸다 — 노선이 마지막 저장 그대로면 null(안 보냄).
|
||||
* · 저장 뒤 `carryAfterRouteSave` 가 세션 횡단 초안을 측점 열쇠로 새 노선에 넘기고(`carryStationDrafts`)
|
||||
* 넘긴 고침을 저장 · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
|
||||
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
|
||||
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
|
||||
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { replanRoute } from "./B05_Profile_Api_Replan";
|
||||
import type { RouteDraft } from "../A00_Common/design/design_pipeline_types";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { currentRoute, remountRoute } from "../A00_Common/router";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
loadSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { carryStationDrafts, useStationKeys } from "../B06_Section/B06_Section_Station_Keys";
|
||||
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
|
||||
import {
|
||||
replanRoute,
|
||||
type RouteReplanResponse,
|
||||
type RouteSaveBody,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
|
||||
type Vertex = [number, number];
|
||||
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
|
||||
export interface RouteApplyParams {
|
||||
overlay: HTMLElement;
|
||||
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
|
||||
busy: HTMLElement;
|
||||
/** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */
|
||||
overlay?: HTMLElement;
|
||||
busy?: HTMLElement;
|
||||
projectId: string;
|
||||
/** 지금 편집값 — 누른 순간에 읽는다. */
|
||||
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
/** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
|
||||
/** 지금 편집값 — 저장하는 순간에 읽는다. */
|
||||
nodes: () => RouteNodes;
|
||||
/** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */
|
||||
changed: () => boolean;
|
||||
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
|
||||
terrain: () => string | null;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
stationSnap: () => boolean;
|
||||
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: {
|
||||
current: () => unknown;
|
||||
keep: () => void;
|
||||
};
|
||||
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
|
||||
profileShown: () => Promise<boolean>;
|
||||
/** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */
|
||||
/** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */
|
||||
onApplied: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */
|
||||
interface RouteSource {
|
||||
pending: () => Promise<RouteSaveBody | null>;
|
||||
settings: () => { stationSnap: boolean; terrain: string | null };
|
||||
saved: () => void;
|
||||
rebuild: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const sources = new Map<string, RouteSource>();
|
||||
|
||||
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
|
||||
export function routeDraftOf(
|
||||
nodes: RouteNodes,
|
||||
settings: { stationSnap: boolean; terrain: string | null },
|
||||
): RouteDraft {
|
||||
return {
|
||||
rev: 0,
|
||||
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
|
||||
curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false),
|
||||
curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null),
|
||||
curveLock: nodes.planned.map(() => null),
|
||||
curveArc: nodes.planned.map(() => null),
|
||||
stationSnap: settings.stationSnap,
|
||||
terrain: settings.terrain,
|
||||
};
|
||||
}
|
||||
|
||||
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const { overlay, busy, projectId } = params;
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
|
||||
// 87.3 · 90.0 · 93.9 · 95.4초).
|
||||
const message = busy.querySelector("span")!;
|
||||
const started = Date.now();
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
await params.onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
const { planned, curveOn, curveRadius } = params.nodes();
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
if (!params.changed()) {
|
||||
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", async () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
{
|
||||
initial: params.initial.current(),
|
||||
// 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다.
|
||||
balanced_profile: await params.profileShown(),
|
||||
terrain: params.terrain(),
|
||||
station_snap: params.stationSnap(),
|
||||
},
|
||||
),
|
||||
);
|
||||
const settings = () => ({ stationSnap: params.stationSnap(), terrain: params.terrain() });
|
||||
sources.set(params.projectId, {
|
||||
pending: async () => {
|
||||
const nodes = params.nodes();
|
||||
if (nodes.planned.length < 2 || !params.changed()) return null;
|
||||
return {
|
||||
draft: routeDraftOf(nodes, settings()),
|
||||
initial: params.initial.current(),
|
||||
// 띠 응답 전이면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다.
|
||||
balanced_profile: await params.profileShown(),
|
||||
};
|
||||
},
|
||||
settings,
|
||||
saved: () => params.initial.keep(),
|
||||
rebuild: params.onApplied,
|
||||
});
|
||||
}
|
||||
|
||||
/** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */
|
||||
export async function pendingRouteSave(projectId: string): Promise<RouteSaveBody | null> {
|
||||
const source = sources.get(projectId);
|
||||
if (source) return source.pending();
|
||||
const draft = useDesignStore(projectId, null).get("routeDraft");
|
||||
return draft && draft.nodes.length >= 2 ? { draft } : null;
|
||||
}
|
||||
|
||||
/** 노선 초안 저장 — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */
|
||||
export async function saveRouteDraft(
|
||||
projectId: string,
|
||||
body: RouteSaveBody,
|
||||
): Promise<RouteReplanResponse> {
|
||||
const saved = await replanRoute(projectId, body);
|
||||
useDesignStore(projectId, null).set("routeDraft", null);
|
||||
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
|
||||
clearResults(projectId);
|
||||
sources.get(projectId)?.saved();
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */
|
||||
export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): string {
|
||||
const parts = [
|
||||
`관 지킴 ${saved.pipes?.kept ?? 0}`,
|
||||
`새 교차 관 ${saved.pipes?.crossing ?? 0}`,
|
||||
`구조물 옮김 ${saved.structures?.moved ?? 0}`,
|
||||
`멀어짐 ${saved.structures?.far ?? 0}`,
|
||||
];
|
||||
if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`);
|
||||
const dropped = (saved.cross_dropped ?? 0) + outside;
|
||||
if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`);
|
||||
if (saved.drainage_stale) parts.push("배수유역 낡음");
|
||||
return `노선 저장 — ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠) → 새 노선으로 넘기고 그 고침을 저장 — 초안을 통째로
|
||||
* 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
|
||||
*/
|
||||
export async function carryAfterRouteSave(
|
||||
projectId: string,
|
||||
fromRouteId: number,
|
||||
toRouteId: number,
|
||||
): Promise<number> {
|
||||
invalidateSectionDetail(projectId, toRouteId);
|
||||
const detail = await loadSectionDetail(projectId, toRouteId);
|
||||
useStationKeys(projectId, toRouteId, detail);
|
||||
const outside = carryStationDrafts(projectId, fromRouteId, toRouteId);
|
||||
await saveCachedCrossPatches(projectId, toRouteId);
|
||||
return outside;
|
||||
}
|
||||
|
||||
/** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */
|
||||
export async function rebuildAfterRouteSave(projectId: string): Promise<void> {
|
||||
const source = sources.get(projectId);
|
||||
if (source) return source.rebuild();
|
||||
clearRouteLatestCache(projectId);
|
||||
invalidateSectionDetail(projectId);
|
||||
remountRoute(currentRoute());
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시
|
||||
* 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음).
|
||||
* 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로.
|
||||
*/
|
||||
export async function saveRecommendedRoute(
|
||||
projectId: string,
|
||||
nodes: RouteNodes,
|
||||
initial: unknown,
|
||||
balancedProfile: boolean,
|
||||
): Promise<boolean> {
|
||||
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveRouteDraft(projectId, {
|
||||
draft: routeDraftOf(nodes, settings),
|
||||
initial,
|
||||
balanced_profile: balancedProfile,
|
||||
as_initial: true,
|
||||
});
|
||||
clearDrafts(projectId);
|
||||
sources.get(projectId)?.saved();
|
||||
showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success");
|
||||
await rebuildAfterRouteSave(projectId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`추천 노선 저장에 실패했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
* 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이
|
||||
* 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
|
||||
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
|
||||
* ========================================================================== */
|
||||
@@ -74,7 +75,7 @@ export function createRouteEditChrome(
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [확인] 때 저장">측점 스냅</button>
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
@@ -83,9 +84,7 @@ export function createRouteEditChrome(
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
|
||||
title="마지막 [노선 반영] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">반영 전으로</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"
|
||||
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">노선 반영</button>
|
||||
title="마지막 [저장] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">저장 전으로</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial.ts
|
||||
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
|
||||
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [노선 반영]에 실을 지금 추천. 칸 요소는
|
||||
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
|
||||
* 밖(`_Panel_Recommend`)에서 받는다.
|
||||
*
|
||||
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
|
||||
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
|
||||
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
|
||||
* `initial_route` 한 곳에서 읽는다. 종단 · 배수유역 · 횡단 다시 계산은 여기서 안 돈다 —
|
||||
* 노선이 바뀐 뒤 [확인]에서만(`_Apply`). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
|
||||
* `initial_route` 한 곳에서 읽는다. 손으로 고친 노선의 다시 계산은 페이지 [저장]에서(`_Apply` · 346-3) —
|
||||
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
|
||||
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
|
||||
*
|
||||
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
|
||||
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 ·
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type InitialChoice,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
|
||||
import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
|
||||
export interface InitialPanelParams {
|
||||
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
|
||||
@@ -61,7 +63,7 @@ export interface InitialPanelParams {
|
||||
export interface InitialPanel {
|
||||
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
|
||||
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
|
||||
/** [확인]에 실어 보낼 지금 추천. */
|
||||
/** [저장]에 실어 보낼 지금 추천. */
|
||||
current: () => {
|
||||
mode: RouteInitialLogic;
|
||||
weights: RouteInitialWeights | null;
|
||||
@@ -160,7 +162,18 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
render();
|
||||
params.apply(
|
||||
copy(picked.snapshot),
|
||||
`노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 다시 계산은 [확인]에서.`,
|
||||
`노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 서버에 저장합니다.`,
|
||||
);
|
||||
// 초기값 = 추천 노선(346-3) — 바로 저장 · 실패하면 평면에 실린 채 남아 [저장]이 다시 보냄.
|
||||
void saveRecommendedRoute(
|
||||
projectId,
|
||||
picked.snapshot,
|
||||
{
|
||||
mode: picked.logic,
|
||||
weights: picked.weights,
|
||||
criteria: picked.criteriaOverrides,
|
||||
},
|
||||
withProfileOf(picked.logic),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -193,7 +206,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
writeInitialChoice(projectId, choice);
|
||||
params.apply(
|
||||
copy(snapshot),
|
||||
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
|
||||
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [저장]에서.",
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
|
||||
@@ -33,6 +33,10 @@ const STUBS = {
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
hideLoadingOverlay: () => calls.push("overlay:off"),
|
||||
},
|
||||
// 노선 저장(346-3) — 이 시험은 노선이 그대로인 [저장]만 봄
|
||||
[path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts")]: {
|
||||
pendingRouteSave: async () => null,
|
||||
},
|
||||
[path.join(B06, "B06_Section_Api_Fetch.ts")]: {
|
||||
fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()),
|
||||
saveSections: async () => calls.push("cross:save"),
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/* 346-3 노선 저장 이음 헬퍼 — `B05_Profile_UI_RouteEdit_Apply.ts` 를 트랜스파일해 서버 · 화면 창구는 가짜로,
|
||||
* 가게 · 캐시(`design_store` · `b_page_state`)는 진짜로 돌림. 보낸 본문 · 부른 차례를 적어 JSON 한 벌로 냄.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
const calls = [];
|
||||
const bodies = [];
|
||||
let failReplan = false;
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}:${text}`),
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
hideLoadingOverlay: () => calls.push("overlay:off"),
|
||||
},
|
||||
"../A00_Common/router": {
|
||||
currentRoute: () => "b05-profile",
|
||||
remountRoute: () => calls.push("remount"),
|
||||
},
|
||||
"../B06_Section/B06_Section_Section_Store": {
|
||||
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
|
||||
loadSectionDetail: async (_p, routeId) => (calls.push(`load:${routeId}`), { tag: routeId }),
|
||||
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
|
||||
},
|
||||
"../B06_Section/B06_Section_Station_Keys": {
|
||||
useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`),
|
||||
carryStationDrafts: (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2),
|
||||
},
|
||||
"./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear") },
|
||||
"./B05_Profile_Api_Replan": {
|
||||
replanRoute: async (_p, body) => {
|
||||
bodies.push(JSON.parse(JSON.stringify(body)));
|
||||
calls.push("replan");
|
||||
if (failReplan) throw new Error("체인 실패");
|
||||
return {
|
||||
route_id: 9,
|
||||
pipes: { kept: 3, crossing: 1, concave: 2 },
|
||||
structures: { moved: 1, far: 0 },
|
||||
cross_dropped: 1,
|
||||
drainage_stale: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
|
||||
const { readState, writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
|
||||
const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts"));
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
const draft = {
|
||||
rev: 3,
|
||||
nodes: [
|
||||
[0, 0],
|
||||
[5, 5],
|
||||
[10, 0],
|
||||
],
|
||||
curveOn: [true, true, true],
|
||||
};
|
||||
// 편집기 없음 — 가게 routeDraft 를 보냄 · 없으면 null
|
||||
out.noneAtAll = await apply.pendingRouteSave("p1");
|
||||
useDesignStore("p1", null).set("routeDraft", draft);
|
||||
out.storeOnly = await apply.pendingRouteSave("p1");
|
||||
|
||||
// 편집기 등록 — 안 바뀌었으면 null(가게에 초안이 있어도 편집기가 정함) · 바뀌면 계약 꼴
|
||||
let changed = false;
|
||||
let kept = 0;
|
||||
let rebuilt = 0;
|
||||
apply.bindRouteApply({
|
||||
projectId: "p1",
|
||||
nodes: () => ({
|
||||
planned: [
|
||||
[0, 0],
|
||||
[5, 5],
|
||||
[10, 0],
|
||||
],
|
||||
curveOn: [true, false],
|
||||
curveRadius: [null, 25],
|
||||
}),
|
||||
changed: () => changed,
|
||||
terrain: () => "sheet",
|
||||
stationSnap: () => true,
|
||||
initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) },
|
||||
profileShown: async () => true,
|
||||
onApplied: () => (rebuilt += 1),
|
||||
});
|
||||
out.unchanged = await apply.pendingRouteSave("p1");
|
||||
changed = true;
|
||||
const body = await apply.pendingRouteSave("p1");
|
||||
out.body = body;
|
||||
|
||||
// 저장 — 가게 초안 비움 · 결과 캐시 비움 · 추천 다시 적음
|
||||
writeState("design-stations", [{ k: 1 }], "p1");
|
||||
const saved = await apply.saveRouteDraft("p1", body);
|
||||
out.savedRouteId = saved.route_id;
|
||||
out.storeAfterSave = useDesignStore("p1", null).get("routeDraft");
|
||||
out.keptAfterSave = kept;
|
||||
out.summary = apply.routeSaveSummary(saved, 2);
|
||||
|
||||
// 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장
|
||||
calls.length = 0;
|
||||
out.outside = await apply.carryAfterRouteSave("p1", 7, 9);
|
||||
out.carryCalls = [...calls];
|
||||
|
||||
// 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움
|
||||
useDesignStore("p1", null).set("routeDraft", draft);
|
||||
writeState("pipes", [{ chainage_m: 1 }], "p1");
|
||||
calls.length = 0;
|
||||
const snapshot = {
|
||||
planned: [
|
||||
[1, 1],
|
||||
[9, 9],
|
||||
],
|
||||
curveOn: [true, true],
|
||||
curveRadius: [null, null],
|
||||
};
|
||||
out.recommendOk = await apply.saveRecommendedRoute("p1", snapshot, { mode: "earthwork" }, true);
|
||||
out.recommendBody = bodies[bodies.length - 1];
|
||||
out.recommendCalls = calls.filter((c) => !c.startsWith("toast:"));
|
||||
out.recommendDraftsLeft = {
|
||||
route: useDesignStore("p1", null).get("routeDraft"),
|
||||
pipes: readState("pipes", "p1"),
|
||||
};
|
||||
out.rebuilt = rebuilt;
|
||||
|
||||
// 추천 저장 실패 — false · 오류 한 줄 · 다시 안 세움
|
||||
failReplan = true;
|
||||
calls.length = 0;
|
||||
out.recommendFail = await apply.saveRecommendedRoute("p1", snapshot, null, false);
|
||||
out.recommendFailCalls = [...calls];
|
||||
out.rebuiltAfterFail = rebuilt;
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
@@ -30,7 +30,22 @@ const record = (name, fail) => async () => {
|
||||
if (fail) throw new Error(`${name} 실패`);
|
||||
};
|
||||
let failStructures = false;
|
||||
// 노선 저장(346-3) — 초안 · 실패를 시험이 정함
|
||||
let routeBody = null;
|
||||
let failRoute = false;
|
||||
const routeStub = {
|
||||
pendingRouteSave: async () => routeBody,
|
||||
saveRouteDraft: async () => {
|
||||
calls.push("route");
|
||||
if (failRoute) throw new Error("노선 저장 실패");
|
||||
return { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 };
|
||||
},
|
||||
carryAfterRouteSave: async (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2),
|
||||
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
|
||||
rebuildAfterRouteSave: async () => calls.push("rebuild"),
|
||||
};
|
||||
const STUBS = {
|
||||
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub,
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}`),
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
@@ -161,6 +176,57 @@ async function run() {
|
||||
});
|
||||
out.noRouteCalls = [...calls];
|
||||
|
||||
// ⑥ 노선이 바뀜(346-3) — 노선 먼저 · 뒤 차례는 새 노선(9) · 화면 몫 건너뜀 · 횡단 초안 넘김 · 다시 세움
|
||||
b05.replace({ station_offsets: { "30.000": 0.2 }, curve_radii: {} }); // 옛 노선(7) 계획고 초안
|
||||
routeBody = {
|
||||
draft: {
|
||||
rev: 1,
|
||||
nodes: [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
],
|
||||
},
|
||||
};
|
||||
calls.length = 0;
|
||||
out.routeOk = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
prepare: record("prepare"),
|
||||
steps: { pipes: record("pipes:screen"), alignment: record("alignment:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.routeCalls = [...calls];
|
||||
// ⑦ 노선 저장 실패 — 거기서 멈춤 · 다른 초안 그대로
|
||||
b05.replace({ station_offsets: { "50.000": 0.3 }, curve_radii: {} });
|
||||
failRoute = true;
|
||||
calls.length = 0;
|
||||
out.routeFailOk = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.routeFailCalls = [...calls];
|
||||
out.routeFailDraft = edit.pendingAlignment(7)?.station_offsets ?? null;
|
||||
failRoute = false;
|
||||
// ⑧ [확정] 중 노선이 바뀜 — 노선 저장 뒤 다시 세움 · 확정 단계는 안 닫음(새 노선으로 다시)
|
||||
calls.length = 0;
|
||||
await saveDesign(
|
||||
{
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
},
|
||||
{ confirm: true },
|
||||
);
|
||||
out.routeConfirmCalls = [...calls];
|
||||
routeBody = null;
|
||||
|
||||
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
|
||||
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
|
||||
edit.clearAlignmentDrafts();
|
||||
|
||||
@@ -291,6 +291,7 @@ def test_화면_확인이_고른_지형을_실음():
|
||||
page = ROOT / "B05_Profile"
|
||||
apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
|
||||
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "terrain: params.terrain()," in apply
|
||||
# 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄
|
||||
assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply
|
||||
assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈
|
||||
assert "analysisTerrain(projectId, designTerrain)" in main # 고른 값 없으면 설계 지형
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김.
|
||||
|
||||
노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면
|
||||
창구만 가짜로 두고 돌림 · 서버 길 시험은 `test_346_3_route_save.py` · 저장 차례(노선 먼저 · 안 바뀌면
|
||||
안 보냄 · 실패 때 초안 남음)는 `test_design_store_save.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
run = subprocess.run(
|
||||
["node", str(HERE / "helper_346_3_route_save.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_노선_안_바뀜_안_보냄(out):
|
||||
assert out["noneAtAll"] is None
|
||||
assert out["unchanged"] is None # 편집기가 정함 — 가게에 초안이 남아 있어도
|
||||
assert out["storeOnly"]["draft"]["rev"] == 3 # 편집기 없으면 가게 routeDraft
|
||||
|
||||
|
||||
def test_초안은_계약_RouteDraft_꼴(out):
|
||||
draft = out["body"]["draft"]
|
||||
assert set(draft) == {
|
||||
"rev",
|
||||
"nodes",
|
||||
"curveOn",
|
||||
"curveRadius",
|
||||
"curveLock",
|
||||
"curveArc",
|
||||
"stationSnap",
|
||||
"terrain",
|
||||
}
|
||||
n = len(draft["nodes"])
|
||||
assert all(
|
||||
len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc")
|
||||
)
|
||||
assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None]
|
||||
assert draft["stationSnap"] is True and draft["terrain"] == "sheet"
|
||||
assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True
|
||||
assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로
|
||||
|
||||
|
||||
def test_저장_뒤_가게_초안_비움_결과_한_줄(out):
|
||||
assert out["savedRouteId"] == 9
|
||||
assert out["storeAfterSave"] is None
|
||||
assert out["keptAfterSave"] == 1
|
||||
assert out["summary"] == (
|
||||
"노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · "
|
||||
"노선 밖 고침 3 건 · 배수유역 낡음"
|
||||
) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄
|
||||
|
||||
|
||||
def test_초안_넘김_차례(out):
|
||||
assert out["carryCalls"] == ["invalidate:9", "load:9", "keys:9:9", "carry:7->9", "crossSave:9"]
|
||||
assert out["outside"] == 2
|
||||
|
||||
|
||||
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
|
||||
assert out["recommendOk"] is True
|
||||
body = out["recommendBody"]
|
||||
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
|
||||
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
|
||||
assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나
|
||||
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
|
||||
assert out["rebuilt"] == 1
|
||||
|
||||
|
||||
def test_추천_저장_실패는_한_줄_다시_안_세움(out):
|
||||
assert out["recommendFail"] is False
|
||||
errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")]
|
||||
assert len(errors) == 1 and "체인 실패" in errors[0]
|
||||
assert out["rebuiltAfterFail"] == 1
|
||||
|
||||
|
||||
def test_노선_반영_단추_없앰_배선():
|
||||
chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert 'data-act="apply"' not in chrome and "노선 반영</button>" not in chrome
|
||||
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
|
||||
assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름
|
||||
save = _read("A00_Common/design/design_save.ts")
|
||||
assert save.index('"route",') < save.index('"pipes",')
|
||||
assert "await pendingRouteSave(ctx.projectId)" in save
|
||||
assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save
|
||||
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
|
||||
at = initial.index("onApply: (picked) => {")
|
||||
assert "saveRecommendedRoute(" in initial[at : at + 900]
|
||||
replan = _read("B05_Profile/B05_Profile_Api_Replan.ts")
|
||||
assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan
|
||||
@@ -33,10 +33,12 @@ def test_겉틀은_본문_칸():
|
||||
assert 'overlay.className = "b05-routeedit";' in chrome
|
||||
assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome
|
||||
assert '"tools"' in chrome and "replaceWith(...tools)" in chrome
|
||||
assert ">반영 전으로</button>" in chrome and ">노선 반영</button>" in chrome
|
||||
# 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · [취소] = [저장 전으로]
|
||||
assert ">저장 전으로</button>" in chrome and 'data-act="apply"' not in chrome
|
||||
assert "노선 반영</button>" not in chrome
|
||||
assert ">취소</button>" not in chrome and ">확인</button>" not in chrome
|
||||
# 시험 · 배선이 집는 자리는 남김
|
||||
for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"):
|
||||
for hook in ('data-act="cancel"', "b05-routeedit__measure-close"):
|
||||
assert hook in chrome
|
||||
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ def test_초기화_계획노선_편집은_다시_세움():
|
||||
assert step in rebuild
|
||||
assert rebuild.index("invalidateSectionDetail") < rebuild.index("remountRoute(")
|
||||
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
|
||||
# 346-3 — [저장]이 노선을 보낸 뒤 결과 캐시를 비우고 편집기가 건 다시 세우기(rebuildAfterReplan)
|
||||
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
|
||||
assert apply.index("clearDrafts(projectId);") < apply.index("await params.onApplied();")
|
||||
assert "clearResults(projectId);" in apply
|
||||
assert "clearResults(projectId);" in apply and "rebuild: params.onApplied," in apply
|
||||
|
||||
|
||||
def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
|
||||
|
||||
@@ -48,7 +48,15 @@ def test_계획고_가게_한_벌(out):
|
||||
|
||||
|
||||
def test_저장_차례(out):
|
||||
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert out["order"] == [
|
||||
"route",
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"culvertOptions",
|
||||
"uphill",
|
||||
"cross",
|
||||
] # 346-3 노선 먼저
|
||||
assert out["saveOk"] is True
|
||||
assert out["saveCalls"] == [
|
||||
"overlay:on",
|
||||
@@ -78,3 +86,43 @@ def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
|
||||
|
||||
def test_초기화는_초안과_한_벌을_버림(out):
|
||||
assert out["cleared"] == {"draft": None, "pending": None}
|
||||
|
||||
|
||||
def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
|
||||
"""346-3 — 노선 초안이 있으면 맨 앞 · 관 · 구조물 기본 창구 · 계획고 초안(옛 노선 7 칸)을 새 노선 9 로 ·
|
||||
화면 몫 일(옛 노선 판)은 건너뜀 · 횡단 초안 넘김(측점 열쇠) · 결과 한 줄 · 다시 세움."""
|
||||
assert out["routeOk"] is True
|
||||
assert out["routeCalls"] == [
|
||||
"overlay:on",
|
||||
"prepare",
|
||||
"route",
|
||||
"pipes",
|
||||
"structures",
|
||||
'alignment:9:{"30.000":0.2}',
|
||||
"culvert:aislo:draft:culvertopt:p1:7",
|
||||
"uphill",
|
||||
"carry:7->9",
|
||||
"summary:9:2",
|
||||
"toast:info",
|
||||
"done:false",
|
||||
"rebuild",
|
||||
"overlay:off",
|
||||
]
|
||||
assert "pipes:screen" not in out["routeCalls"] and "alignment:screen" not in out["routeCalls"]
|
||||
assert "cross:false" not in out["routeCalls"] # 옛 노선 횡단 본문 대신 넘김
|
||||
|
||||
|
||||
def test_노선이_그대로면_안_보냄(out):
|
||||
assert "route" not in out["saveCalls"]
|
||||
|
||||
|
||||
def test_노선_저장_실패는_멈추고_초안_남음(out):
|
||||
assert out["routeFailOk"] is False
|
||||
assert out["routeFailCalls"] == ["overlay:on", "route", "failed", "overlay:off"]
|
||||
assert out["routeFailDraft"] == {"50.000": 0.3} # 계획고 초안 그대로
|
||||
|
||||
|
||||
def test_확정_중_노선이_바뀌면_다시_세우고_확정은_다시(out):
|
||||
calls = out["routeConfirmCalls"]
|
||||
assert calls[1] == "route" and calls[-2:] == ["rebuild", "overlay:off"]
|
||||
assert "done:true" not in calls and "cross:true" not in calls
|
||||
|
||||
Reference in New Issue
Block a user