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:
2026-10-18 21:29:23 +09:00
co-authored by Claude Opus 5.5
parent d0779af302
commit 33201b14ef
11 changed files with 328 additions and 36 deletions
+31 -3
View File
@@ -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 {
+3 -8
View File
@@ -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,
+10 -2
View File
@@ -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
+5 -4
View File
@@ -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);
}
+2 -11
View File
@@ -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);
+152
View File
@@ -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);
});
+105
View File
@@ -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