fix(443 B05 저장): 구조물 저장 실패 = 초안 남기고 그 차례에서 멈춤 · 상단측 차례 성공 뒤 세션 비움 · 확정 하위 실패 알림

- 구조물(H6) — 409 · 그 밖 실패를 토스트로 삼키지 않고 던짐 · 저장 줄이 앞 실패에 막히지 않음
- 상단측(M3) — 차례 성공하면 uphill 세션 비움 · 종단 보기도 세션에서 읽음
- 확정 하위(M6) — 응답 failed_parts(비정규 측점 횡단 · 상단측 병합 · 종단 없음) · 화면이 던짐
- 시험 test_443_save_stops.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-18 18:35:14 +09:00
co-authored by Claude Opus 5.5
parent 756d690819
commit f2338bd5c8
7 changed files with 367 additions and 35 deletions
+12 -1
View File
@@ -101,6 +101,8 @@ export interface RouteConfirmResponse {
project_id: string;
route_id: number;
confirmed: boolean;
/** 실패한 하위 차례(비정규 측점 횡단 · 상단측 병합) — 확정은 됨(443). */
failed_parts?: string[];
}
export interface RouteLatestResponse {
@@ -278,7 +280,16 @@ export async function flushUphillOverrides(projectId: string): Promise<void> {
.filter(([, side]) => side === "left" || side === "right")
.map(([chainage, side]) => ({ chainage_m: Number(chainage), side }));
if (!overrides.length) return;
await confirmRoute(projectId, { uphill_overrides: overrides }, false);
await saveRouteParts(projectId, { uphill_overrides: overrides });
}
/** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 ·
* 세션 그대로). 다 되면 보낸 상단측 세션을 비움 — 안 비우면 매 [저장]이 옛 값을 다시 보냄. */
export async function saveRouteParts(projectId: string, body: RouteConfirmRequest): Promise<void> {
const saved = await confirmRoute(projectId, body, false);
const failed = saved.failed_parts ?? [];
if (failed.length) throw new Error(`${failed.join(" · ")} 실패`);
if (body.uphill_overrides?.length) clearState("uphill", projectId);
}
/** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */
+9 -1
View File
@@ -58,6 +58,8 @@ async def confirm_latest_route(
status_code=404,
content={"status": "error", "message": "확정할 경로가 없습니다."},
)
# 하위 차례 실패 — 확정은 진행하되 응답에 실어 화면이 그 차례에서 멈추게(443 · M6)
failed_parts: list[str] = []
if request.can_regenerate():
try:
await _append_irregular_cross_sections(connection, project_id, latest, request)
@@ -68,6 +70,7 @@ async def confirm_latest_route(
project_id,
latest["id"],
)
failed_parts.append("비정규 측점 횡단")
# 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적.
if request.uphill_overrides:
try:
@@ -94,12 +97,15 @@ async def confirm_latest_route(
str(longitudinal["longitudinal_file_path"]),
overrides,
)
else:
failed_parts.append("상단측 병합(종단 없음)")
except Exception:
logger.exception(
"B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s",
project_id,
latest["id"],
)
failed_parts.append("상단측 병합")
await connection.begin()
try:
log_b05_debug(
@@ -132,7 +138,9 @@ async def confirm_latest_route(
reason=str(exc),
)
raise
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
return RouteConfirmResponse(
project_id=str(project_id), route_id=latest["id"], failed_parts=failed_parts
)
except Exception:
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
return JSONResponse(
+2
View File
@@ -298,6 +298,8 @@ class RouteConfirmResponse(BaseModel):
project_id: str
route_id: int
confirmed: bool = True
#: 실패한 하위 차례(비정규 측점 횡단 · 상단측 병합) — 확정은 됨 · 화면이 알리고 멈춤(443)
failed_parts: list[str] = Field(default_factory=list)
class RouteLatestResponse(BaseModel):
+25 -23
View File
@@ -7,7 +7,8 @@
* 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다.
* ========================================================================== */
import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import { saveRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import { loadUphillOverrides } from "./B05_Profile_UI_Page_Helpers";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
@@ -42,34 +43,35 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
return {
routeId: () => (ctx.routeReady() ? pageRouteId(projectId) : null), // 노선 id 한 칸(386-2b)
steps: {
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고
// 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장).
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 초안을 남기고 던져
// 저장 한 줄기가 이 차례에서 멈춘다(443).
structures: () => ctx.bridge().saveStructuresIfDirty(),
// 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
// 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443). 상단측은 세션에서 읽음 — 노선 바꾼 [저장]이
// 기본 창구로 세션을 비웠으면 메모리 값을 다시 보내지 않게.
uphill: async () => {
const latest = ctx.latest();
await confirmRoute(
projectId,
{
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
irregular_stations: ctx
.bridge()
.irregularStations()
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
await saveRouteParts(projectId, {
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
irregular_stations: ctx
.bridge()
.irregularStations()
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...loadUphillOverrides(projectId).entries()].map(
([chainage, side]) => ({
chainage_m: Number(chainage),
side,
})),
},
false,
);
}),
),
});
ctx.uphillOverrides.clear();
},
},
// 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움)
+8 -10
View File
@@ -216,8 +216,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
// 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는
// 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐).
structureSaving = structureSaving.then(() => persistStructures(readPending() ?? ownStructures));
await structureSaving;
// 실패는 저장 한 줄기로 던짐(그 차례에서 멈춤 · 443) — 줄은 이어 다음 [저장]이 막히지 않게.
const run = structureSaving.then(() => persistStructures(readPending() ?? ownStructures));
structureSaving = run.catch(() => undefined);
await run;
}
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
@@ -262,14 +264,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
} catch (error) {
// 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다
// (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다.
if (error instanceof StructureConflictError) {
showToast(
"다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.",
"error",
);
return;
}
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
// 삼키지 않고 던짐 — 저장 한 줄기가 이 차례에서 멈추고 알림(443 · 「저장했습니다」 안 뜸).
if (error instanceof StructureConflictError)
throw new Error("다른 창에서 구조물이 먼저 저장됐습니다 — 다시 [저장]해 주세요.");
throw error instanceof Error ? error : new Error("구조물 저장에 실패했습니다.");
}
}
+110
View File
@@ -0,0 +1,110 @@
/* PLAN 443 단위검증 헬퍼 — 저장 누락 ① (H6 · M3 · M6).
* H6 구조물 저장 실패(409 · 그 밖) = 던짐 · 초안 남음 · 다음 [저장]이 줄에 안 막힘
* M3 상단측 차례 성공 = uphill 세션 비움 · 실패 = 그대로
* M6 확정 응답 failed_parts = 던짐(그 차례에서 멈춤)
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_443_save_stops.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
globalThis.window = { setTimeout, clearTimeout };
const anything = () =>
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
function load(file, stubs) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything());
return box.exports;
}
const settle = (promise) =>
promise.then(
() => "ok",
(error) => String(error.message),
);
async function run() {
const out = {};
// ── H6 구조물 ──
class StructureConflictError extends Error {}
let pending = [{ structure_id: "s1" }];
let mode = "conflict";
const puts = [];
const bridgeMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Page_Structures.ts"), {
"./B05_Profile_Api_Structures": {
StructureConflictError,
fetchStructures: async () => ({ revision: 3, structures: [] }),
saveStructures: async (_p, revision, list) => {
puts.push(revision);
if (mode === "conflict") throw new StructureConflictError("409");
if (mode === "boom") throw new Error("서버 500");
return { revision: revision + 1 };
},
readPendingStructures: () => pending,
writePendingStructures: (_p, next) => (pending = next),
definitionStale: () => false,
staleDefinitionNotice: () => null,
},
"@ui/ui_template_elements": { showToast: () => undefined },
});
const quiet = new Proxy({}, { get: () => () => undefined });
const bridge = bridgeMod.createStructuresBridge({
projectId: "p1",
panel: () => ({ structures: quiet, values: () => ({}) }),
profilePanel: () => quiet,
renderStationLines: () => undefined,
isRestoring: () => false,
});
out.conflict = await settle(bridge.saveStructuresIfDirty());
out.pendingAfterConflict = pending;
mode = "boom";
out.boom = await settle(bridge.saveStructuresIfDirty()); // 앞 실패에 줄이 안 막힘
out.pendingAfterBoom = pending;
mode = "ok";
out.ok = await settle(bridge.saveStructuresIfDirty());
out.pendingAfterOk = pending;
out.puts = puts;
// ── M3 · M6 상단측 · 확정 하위 ──
const state = new Map();
let reply = {};
const bodies = [];
globalThis.fetch = async (_url, init) => {
bodies.push(JSON.parse(init.body));
return { ok: true, json: async () => reply };
};
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts"), {
"../A00_Common/b_page_state": {
readState: (name, p) => state.get(`${name}:${p}`) ?? null,
writeState: (name, value, p) => state.set(`${name}:${p}`, value),
clearState: (name, p) => state.delete(`${name}:${p}`),
},
"@config/config_frontend": { API_BASE_URL: "", API_TIMEOUT_MS: 1000 },
});
state.set("uphill:p1", { "40.000": "right" });
reply = { failed_parts: ["상단측 병합"] };
out.flushFailed = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterFail = state.get("uphill:p1") ?? null;
reply = { failed_parts: [] };
out.flushOk = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterOk = state.get("uphill:p1") ?? null;
out.sentAgain = await settle(api.flushUphillOverrides("p1")); // 비었으니 안 보냄
out.bodies = bodies;
reply = { failed_parts: ["비정규 측점 횡단"] };
out.partsFailed = await settle(api.saveRouteParts("p1", { irregular_stations: [] }));
return out;
}
run().then(
(out) => process.stdout.write(JSON.stringify(out)),
(error) => {
process.stderr.write(String(error.stack ?? error));
process.exit(1);
},
);
+201
View File
@@ -0,0 +1,201 @@
"""PLAN 443 — 저장 누락 ① 구조물 실패 · 상단측 재전송 · 확정 하위 실패(H6 · M3 · M6).
- H6 구조물 저장 실패(409 · 그 밖) = 던짐(저장 한 줄기가 구조물 차례에서 멈춤) · 초안 남음 · 줄 안 막힘.
- M3 상단측 차례 성공 = uphill 세션 비움(다음 [저장]이 다시 안 보냄) · 실패 = 세션 그대로.
- M6 B05 확정 하위(비정규 측점 횡단 · 상단측 병합) 실패 = 응답 failed_parts · 화면이 던짐.
Node 헬퍼 = `helper_443_save_stops.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_443_save_stops.py -q
"""
import asyncio
import json
import subprocess
from pathlib import Path
from uuid import uuid4
import pytest
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", str(HERE / "helper_443_save_stops.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
# ── H6 ───────────────────────────────────────────────────────────────────────
def test_구조물_409_던짐_초안_남음(out):
assert "먼저 저장" in out["conflict"]
assert out["pendingAfterConflict"] == [{"structure_id": "s1"}]
def test_구조물_실패_뒤_줄이_안_막힘(out):
assert out["boom"] == "서버 500" and out["pendingAfterBoom"] == [{"structure_id": "s1"}]
assert out["ok"] == "ok" and out["pendingAfterOk"] is None
assert out["puts"] == [3, 3, 3] # 세 번 다 나감
def test_구조물_차례가_토스트로_삼키지_않음():
page = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Structures.ts").read_text("utf-8")
body = page.split("async function persistStructures(")[1].split("\n }\n")[0]
assert "showToast(error" not in body and "return;\n" not in body.split("catch (error)")[1]
# ── M3 · M6 화면 ─────────────────────────────────────────────────────────────
def test_상단측_실패면_세션_그대로_성공이면_비움(out):
assert out["flushFailed"] == "상단측 병합 실패"
assert out["uphillAfterFail"] == {"40.000": "right"}
assert out["flushOk"] == "ok" and out["uphillAfterOk"] is None
assert len(out["bodies"]) == 3 # 실패 · 성공 · (빈 세션은 안 보냄) · 확정 하위 하나
def test_확정_하위_실패면_던짐(out):
assert out["partsFailed"] == "비정규 측점 횡단 실패"
def test_종단_보기_상단측_차례도_같은_창구():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
assert "await saveRouteParts(projectId, {" in actions
assert "loadUphillOverrides(projectId)" in actions # 세션에서 읽음(메모리 옛 값 안 보냄)
assert "confirmRoute(" not in actions
# ── M6 서버 ──────────────────────────────────────────────────────────────────
class _Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class _Connection:
def __init__(self):
self.committed = False
async def begin(self):
pass
async def commit(self):
self.committed = True
async def rollback(self):
pass
def cursor(self):
return _Cursor()
class _Pool:
def __init__(self, connection):
self.connection = connection
def acquire(self):
connection = self.connection
class _Ctx:
async def __aenter__(self):
return connection
async def __aexit__(self, *exc):
return False
return _Ctx()
def _patch(monkeypatch, connection, *, append, longitudinal, merge):
async def latest(*_a):
return {"id": 7, "status": "DRAFT"}
async def noop(*_a, **_k):
return None
async def storage(*_a):
return "p"
async def get_long(*_a):
return longitudinal
monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool(connection))
monkeypatch.setattr(lifecycle, "get_latest_route", latest)
monkeypatch.setattr(lifecycle, "confirm_route", noop)
monkeypatch.setattr(lifecycle, "complete_stage", noop)
monkeypatch.setattr(lifecycle, "_append_irregular_cross_sections", append)
monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", storage)
monkeypatch.setattr(lifecycle, "get_longitudinal_section", get_long)
monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda p: p)
monkeypatch.setattr(lifecycle, "_merge_uphill_overrides_into_longitudinal", merge)
monkeypatch.setattr(lifecycle, "sync_uphill_overrides_into_designs", noop)
def _boom(*_a, **_k):
raise RuntimeError("boom")
async def _aboom(*_a, **_k):
raise RuntimeError("boom")
_REQUEST = RouteConfirmRequest(
filter_key="csf",
method="tin",
surface_model_id=1,
uphill_overrides=[{"chainage_m": 40.0, "side": "right"}],
)
def test_확정_하위_실패를_응답에_실음(monkeypatch):
connection = _Connection()
_patch(
monkeypatch,
connection,
append=_aboom,
longitudinal={"longitudinal_file_path": "l.json"},
merge=_boom,
)
result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False))
assert result.failed_parts == ["비정규 측점 횡단", "상단측 병합"]
assert connection.committed # 확정 자체는 진행(비치명)
def test_종단_없으면_상단측_병합_실패(monkeypatch):
async def ok(*_a, **_k):
return None
_patch(monkeypatch, _Connection(), append=ok, longitudinal=None, merge=lambda *a: None)
result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False))
assert result.failed_parts == ["상단측 병합(종단 없음)"]
def test_다_되면_빈_목록(monkeypatch):
async def ok(*_a, **_k):
return None
_patch(
monkeypatch,
_Connection(),
append=ok,
longitudinal={"longitudinal_file_path": "l.json"},
merge=lambda *a: None,
)
result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False))
assert result.failed_parts == []