fix(449 B05 저장): 관 정본 PUT 은 세션 초안 한 번 · 노선 저장 뒤 실패면 초안을 새 노선 칸으로 · 측점 맞춤 실패 알림
- 433-7 배수판 메모리 다시 PUT 걷음(판 안 선 [저장] 빈 목록 PUT 막음) · [확정] 초안 없음 = 칸 없이 다시 셈 · 판은 저장분 다시 받기만 - 433-12 노선 저장 뒤 다른 차례 실패 = 0 칸 계획고 → 새 노선 칸 · 횡단 초안 넘김(저장 없이 · 두 번 안 넘김) · 새 노선으로 다시 세움 - 433-6 관 PUT 끝 측점 맞춤 실패 = 응답 stations_failed · 알림 - 시험 test_449_save_pipes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
|
||||
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
|
||||
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
|
||||
* 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12).
|
||||
* 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄).
|
||||
* 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④).
|
||||
*
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import {
|
||||
markAlignmentSaved,
|
||||
pendingAlignment,
|
||||
writeAlignmentDraft,
|
||||
} 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";
|
||||
@@ -70,7 +72,7 @@ export interface DesignSaveContext {
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
@@ -140,16 +142,35 @@ async function flushAlignmentDraft(
|
||||
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
|
||||
}
|
||||
|
||||
/** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새
|
||||
* 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */
|
||||
async function keepDraftsOnNewRoute(
|
||||
projectId: string,
|
||||
fromRouteId: number,
|
||||
routeId: number,
|
||||
carried: boolean,
|
||||
): Promise<void> {
|
||||
const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId);
|
||||
if (draft) {
|
||||
writeAlignmentDraft(routeId, draft);
|
||||
markAlignmentSaved(DRAFT_ROUTE_ID);
|
||||
markAlignmentSaved(fromRouteId);
|
||||
}
|
||||
if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false });
|
||||
await rebuildAfterRouteSave(projectId);
|
||||
}
|
||||
|
||||
/** 차례마다 기본 창구. */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross" | "route">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
fromRouteId: number = routeId,
|
||||
confirm = false,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
return () => flushPendingPipes(projectId, confirm);
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2)
|
||||
@@ -197,6 +218,8 @@ export async function saveDesign(
|
||||
let routeId = fromRouteId;
|
||||
/** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */
|
||||
let routeSaved: Awaited<ReturnType<typeof saveRouteDraft>> | null = null;
|
||||
/** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */
|
||||
let carried = false;
|
||||
showLoadingOverlay();
|
||||
saving.add(ctx.projectId);
|
||||
try {
|
||||
@@ -215,6 +238,7 @@ export async function saveDesign(
|
||||
if (step === "cross") {
|
||||
if (!routeSaved) await ctx.cross(confirm);
|
||||
else {
|
||||
carried = true;
|
||||
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
|
||||
showToast(routeSaveSummary(routeSaved, outside), "info");
|
||||
}
|
||||
@@ -224,7 +248,7 @@ export async function saveDesign(
|
||||
if (step === "uphill" && routeSaved) continue;
|
||||
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
|
||||
const own = routeSaved ? undefined : ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
|
||||
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
@@ -250,6 +274,10 @@ export async function saveDesign(
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (routeSaved)
|
||||
await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch(
|
||||
() => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄)
|
||||
);
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -393,14 +393,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
routeId: () => routeIdOf(shared),
|
||||
prepare: crossSave?.prepare,
|
||||
steps: {
|
||||
// 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신).
|
||||
...(side
|
||||
? {
|
||||
pipes: async () => {
|
||||
await side.savePipes(confirm);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판
|
||||
// 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음).
|
||||
...(side ? { pipes: async () => side.reload(confirm) } : {}),
|
||||
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
|
||||
...(profile ? { alignment: () => profile.save() } : {}),
|
||||
...profileSave?.steps,
|
||||
|
||||
@@ -588,6 +588,8 @@ export interface DetailBasinResponse {
|
||||
upstream_lonlat: Array<Array<[number, number]>>;
|
||||
/** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */
|
||||
snapshot_failed?: string[];
|
||||
/** 관 PUT 끝 측점 맞춤 실패(433-6 · 관 저장은 그대로). */
|
||||
stations_failed?: boolean;
|
||||
/** 지문(342-3) — 지금 노선 · 관 자리. */
|
||||
route_signature?: string;
|
||||
pipe_signature?: string;
|
||||
@@ -645,15 +647,21 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
|
||||
export async function saveDetailPipePoints(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[],
|
||||
points: DetailPipeInput[] | null,
|
||||
recompute = false,
|
||||
): Promise<DetailBasinResponse> {
|
||||
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
|
||||
// points null = 칸 없이 — 서버 저장분 그대로(433-7).
|
||||
const saved = await requestJson<DetailBasinResponse>(
|
||||
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
|
||||
{ method: "PUT", body: JSON.stringify({ points }) },
|
||||
{ method: "PUT", body: JSON.stringify(points ? { points } : {}) },
|
||||
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
|
||||
);
|
||||
noteSnapshotFailed(saved.snapshot_failed);
|
||||
if (saved.stations_failed)
|
||||
showToast(
|
||||
"관은 저장했지만 관 자리 측점을 못 맞췄습니다 — 횡단 「측점 없는 관」 알림에서 [측점 만들기]를 눌러 주세요.",
|
||||
"error",
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -571,11 +571,12 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
return _payload(project_id, context, detail, points, False, stale)
|
||||
|
||||
|
||||
async def _stand_pipe_stations(project_id: UUID) -> None:
|
||||
async def _stand_pipe_stations(project_id: UUID) -> bool:
|
||||
"""관 PUT 끝 — 측점을 지금 관 자리로 다시 세움(`put_plus_stations` 처럼 · 부르는 길 모두 · 428).
|
||||
|
||||
노선 [저장] 뒤 관 차례가 화면 누가거리로 관을 덮으면 서버가 노선 저장 때 세운 측점과 어긋남 —
|
||||
여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로.
|
||||
여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로 ·
|
||||
실패 = 거짓(응답 `stations_failed` 로 알림 · 433-6).
|
||||
"""
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
|
||||
@@ -583,6 +584,8 @@ async def _stand_pipe_stations(project_id: UUID) -> None:
|
||||
await ensure_structure_stations(project_id)
|
||||
except Exception:
|
||||
logger.exception("관 저장: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
@@ -627,8 +630,9 @@ async def put_pipe_points(
|
||||
failed = await asyncio.to_thread(
|
||||
safe_sync, resolve_stored_project_path(context.stored_path), importer(session)
|
||||
)
|
||||
await _stand_pipe_stations(project_id)
|
||||
stood = await _stand_pipe_stations(project_id)
|
||||
result = _payload(project_id, context, detail, points, True, stale)
|
||||
result["saved_count"] = saved
|
||||
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
|
||||
result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림
|
||||
return result
|
||||
|
||||
@@ -27,10 +27,11 @@ export function writePendingPipes(projectId: string, next: PendingPipes | null):
|
||||
useDesignStore(projectId, null).set("pipes", next);
|
||||
}
|
||||
|
||||
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
|
||||
export async function flushPendingPipes(projectId: string): Promise<void> {
|
||||
/** 초안이 있으면 정본에 쓰고 비운다 — 관 정본 PUT 은 이 한 번뿐(433-7 · 배수판 메모리는 안 보냄). 빈 목록 =
|
||||
* 사용자가 다 지움. 초안이 없으면 [저장]은 아무 일도 안 함 · [확정]은 `points` 없이(서버 저장분으로 다시 셈). */
|
||||
export async function flushPendingPipes(projectId: string, confirm = false): Promise<void> {
|
||||
const pending = readPendingPipes(projectId);
|
||||
if (!pending) return;
|
||||
await saveDetailPipePoints(projectId, pending);
|
||||
if (!pending && !confirm) return;
|
||||
await saveDetailPipePoints(projectId, pending, confirm);
|
||||
writePendingPipes(projectId, null);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
computeDetailBasins,
|
||||
fetchDetailPipePoints,
|
||||
getVWorldMapUrl,
|
||||
saveDetailPipePoints,
|
||||
type DetailBasin,
|
||||
type DetailBasinResponse,
|
||||
type PipeSource,
|
||||
@@ -566,7 +565,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
projectId = nextProjectId;
|
||||
void loadLayers();
|
||||
},
|
||||
reload() {
|
||||
reload(fresh = false) {
|
||||
if (fresh) live.markFresh(); // [확정] 뒤 — 낡음 걷음(응답이 아직 낡았으면 다시 닮)
|
||||
if (projectId && meta) void run(loadSaved);
|
||||
},
|
||||
pipeChainages: () => pipeEditor.chainages(),
|
||||
@@ -667,15 +667,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection(notify);
|
||||
scheduleDraw();
|
||||
},
|
||||
async savePipes(confirm = false) {
|
||||
if (!projectId) return 0;
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
|
||||
if (confirm && !response.stale) live.markFresh();
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
},
|
||||
setRoute(points) {
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
||||
load: (projectId: string) => void;
|
||||
/** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */
|
||||
reload: () => void;
|
||||
/** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b) · [저장] 뒤
|
||||
* 저장분으로(초안 비움 뒤 · 433-7). `fresh` = [확정] 뒤 낡음 걷음. */
|
||||
reload: (fresh?: boolean) => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
||||
@@ -42,9 +43,6 @@ export interface DrainagePanel {
|
||||
attributes: FacilityAttributes,
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
|
||||
savePipes: (confirm?: boolean) => Promise<number>;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
|
||||
@@ -147,6 +147,13 @@ export function pendingAlignment(routeId: number): AlignmentEdits | null {
|
||||
return readAlignmentDraft(routeId);
|
||||
}
|
||||
|
||||
/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */
|
||||
export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void {
|
||||
draftOf(routeId).set("alignmentDraft", edits);
|
||||
const state = shared.get(routeId);
|
||||
if (state) Object.assign(state, { current: edits, unsaved: true });
|
||||
}
|
||||
|
||||
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
|
||||
export function markAlignmentSaved(routeId: number): void {
|
||||
const state = shared.get(routeId);
|
||||
|
||||
@@ -160,11 +160,13 @@ const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
|
||||
/**
|
||||
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
|
||||
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
|
||||
* `save` 거짓 = 넘기기만(뒤 차례 실패 · 433-12 — 다음 [저장]이 새 노선 초안을 보냄).
|
||||
*/
|
||||
export async function carryAfterRouteSave(
|
||||
projectId: string,
|
||||
fromRouteId: number,
|
||||
toRouteId: number,
|
||||
{ save = true }: { save?: boolean } = {},
|
||||
): Promise<number> {
|
||||
invalidateSectionDetail(projectId, toRouteId);
|
||||
const detail = await loadSectionDetail(projectId, toRouteId);
|
||||
@@ -192,6 +194,7 @@ export async function carryAfterRouteSave(
|
||||
const uphillDropped = uphillBefore - uphillCount();
|
||||
if (uphillDropped > 0)
|
||||
showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info");
|
||||
if (!save) return outside;
|
||||
await flushUphillOverrides(projectId);
|
||||
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
|
||||
withDraftAdjusts(detail, projectId, toRouteId);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/* PLAN 449 헬퍼 — [저장] 관 차례 · 노선 저장 뒤 실패(433-7 · 433-12). 실제 `design_save.ts` · 관 초안 창구 · 가게를
|
||||
* Node 로 · 서버 창구 · 계획고 초안 · 노선 편집 창구만 가짜. 짝 = `test_449_save_pipes.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
globalThis.window = {
|
||||
sessionStorage: memoryStorage(),
|
||||
localStorage: memoryStorage(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
const STUBS = {};
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
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 (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
|
||||
if (key) return STUBS[key];
|
||||
if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" };
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
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 load = (...parts) => loadTs(path.join(ROOT, ...parts));
|
||||
|
||||
let calls = [];
|
||||
let failAt = null; // 이 차례에서 throw
|
||||
let routeBody = null;
|
||||
const alignment = new Map(); // 노선 칸 → 계획고 초안
|
||||
STUBS["ui_template_elements"] = {
|
||||
showLoadingOverlay: () => undefined,
|
||||
hideLoadingOverlay: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
const failing = (name) => {
|
||||
if (failAt === name) throw new Error(`${name} 409`);
|
||||
};
|
||||
STUBS["B04_PreProcess_Api_Fetch"] = {
|
||||
fetchDetailPipePoints: async () => (calls.push(["GET"]), { pipe_points: [] }),
|
||||
saveDetailPipePoints: async (_projectId, points, recompute) => {
|
||||
calls.push(["PUT", points ?? null, !!recompute]);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
STUBS["B05_Profile_Api_Structures"] = {
|
||||
flushPendingStructures: async () => failing("structures"),
|
||||
};
|
||||
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
|
||||
STUBS["B05_Profile_Api_Fetch"] = {
|
||||
flushUphillOverrides: async () => undefined,
|
||||
saveProfileAlignment: async (_projectId, routeId, draft) => {
|
||||
calls.push(["ALIGN", routeId, draft]);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
STUBS["B05_Profile_UI_Profile_Edit"] = {
|
||||
pendingAlignment: (routeId) => alignment.get(routeId) ?? null,
|
||||
markAlignmentSaved: (routeId) => alignment.delete(routeId),
|
||||
writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits),
|
||||
};
|
||||
STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined };
|
||||
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
|
||||
pendingRouteSave: async () => routeBody,
|
||||
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
|
||||
carryAfterRouteSave: async (_projectId, from, to, options) => {
|
||||
calls.push(["CARRY", from, to, options?.save ?? true]);
|
||||
failing("carry");
|
||||
return 0;
|
||||
},
|
||||
routeSaveSummary: () => "",
|
||||
rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]),
|
||||
};
|
||||
const save = load("A00_Common", "design", "design_save.ts");
|
||||
const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts");
|
||||
|
||||
const P = "proj-449";
|
||||
async function once({ route = false, draft = null, confirm = false, fail = null, routeId = 7 }) {
|
||||
calls = [];
|
||||
failAt = fail;
|
||||
routeBody = route ? { draft: {} } : null;
|
||||
drafts.writePendingPipes(P, draft);
|
||||
let failure = null;
|
||||
const ok = await save.saveDesign(
|
||||
{
|
||||
projectId: P,
|
||||
routeId: () => routeId,
|
||||
// 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음
|
||||
steps: { pipes: async () => void calls.push(["RELOAD"]) },
|
||||
cross: async () => void calls.push(["CROSS"]),
|
||||
failed: (error) => (failure = String(error && error.message ? error.message : error)),
|
||||
},
|
||||
{ confirm },
|
||||
);
|
||||
return { ok, failure, calls, left: drafts.readPendingPipes(P) };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
window.localStorage.setItem("frd_current_project_id", P);
|
||||
const fifteen = [{ chainage_m: 15, source: "user", id: "p1" }];
|
||||
const out = {};
|
||||
out.draft = await once({ draft: fifteen }); // B06 고침 직후 [저장] = 초안 값
|
||||
out.noDraft = await once({}); // 판 안 섬 · 초안 없음 = PUT 없음
|
||||
out.confirmNoDraft = await once({ confirm: true }); // [확정] = 칸 없이(서버 저장분)
|
||||
out.emptyDraft = await once({ draft: [] }); // 사용자가 다 지움 = 빈 목록
|
||||
// 433-12 — 노선 저장 뒤 구조물 실패 → 0 칸 계획고가 새 노선 칸에 · 다음 [저장]이 보냄
|
||||
alignment.clear();
|
||||
alignment.set(0, { pvi: { 120: 1.5 } });
|
||||
out.routeFail = await once({ route: true, fail: "structures" });
|
||||
out.alignAfterFail = Object.fromEntries(alignment);
|
||||
out.nextSave = await once({ routeId: 8 });
|
||||
// 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김
|
||||
alignment.clear();
|
||||
out.carryFail = await once({ route: true, fail: "carry" });
|
||||
return out;
|
||||
}
|
||||
|
||||
run()
|
||||
.then((out) => {
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"""PLAN 449 — B05 [저장] 관 목록 · 노선 저장 뒤 실패(433-7 · 433-12 · 433-6).
|
||||
|
||||
· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄
|
||||
· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움
|
||||
· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 →
|
||||
다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김
|
||||
· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed`
|
||||
짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
if not (ROOT / "config" / "node_modules" / "typescript").is_dir():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_449_save_pipes.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _puts(case: dict) -> list:
|
||||
return [call for call in case["calls"] if call[0] == "PUT"]
|
||||
|
||||
|
||||
def test_관_PUT_은_초안_한_번(out):
|
||||
case = out["draft"]
|
||||
assert case["ok"] and _puts(case) == [
|
||||
["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
|
||||
]
|
||||
assert case["calls"].index(["RELOAD"]) > case["calls"].index(
|
||||
_puts(case)[0]
|
||||
) # 화면은 뒤에 받기만
|
||||
assert case["left"] is None
|
||||
|
||||
|
||||
def test_초안_없으면_PUT_없음(out):
|
||||
assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == []
|
||||
|
||||
|
||||
def test_확정은_칸_없이_다시_셈(out):
|
||||
assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]]
|
||||
|
||||
|
||||
def test_빈_초안은_다_지움(out):
|
||||
assert _puts(out["emptyDraft"]) == [["PUT", [], False]]
|
||||
|
||||
|
||||
def test_노선_저장_뒤_실패_초안_새_노선(out):
|
||||
case = out["routeFail"]
|
||||
assert not case["ok"] and "구조물" in case["failure"]
|
||||
assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]]
|
||||
assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸
|
||||
align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"]
|
||||
assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]]
|
||||
|
||||
|
||||
def test_넘김_실패면_두_번_안_넘김(out):
|
||||
case = out["carryFail"]
|
||||
assert not case["ok"]
|
||||
assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]]
|
||||
assert case["calls"][-1] == ["REBUILD"]
|
||||
|
||||
|
||||
def test_공용_저장이_판_메모리를_안_보냄():
|
||||
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
|
||||
panel = (ROOT / "B05_Profile/B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8")
|
||||
assert "savePipes" not in shared and "saveDetailPipePoints" not in panel
|
||||
|
||||
|
||||
def test_측점_맞춤_실패는_응답에(monkeypatch):
|
||||
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
||||
from B06_Section import B06_Section_Router_Stations as stations
|
||||
|
||||
async def boom(_project_id):
|
||||
raise RuntimeError("샘플링 없음")
|
||||
|
||||
async def fine(_project_id):
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(stations, "ensure_structure_stations", boom)
|
||||
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is False
|
||||
monkeypatch.setattr(stations, "ensure_structure_stations", fine)
|
||||
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is True
|
||||
Reference in New Issue
Block a user