From f2338bd5c8827134c9bc779d83778290c82f743a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:35:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(443=20B05=20=EC=A0=80=EC=9E=A5):=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=3D=20=EC=B4=88=EC=95=88=20=EB=82=A8=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=EA=B7=B8=20=EC=B0=A8=EB=A1=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=88=EC=B6=A4=20=C2=B7=20=EC=83=81=EB=8B=A8=EC=B8=A1=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=B9=84=EC=9B=80=20=C2=B7=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=8B=A4=ED=8C=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물(H6) — 409 · 그 밖 실패를 토스트로 삼키지 않고 던짐 · 저장 줄이 앞 실패에 막히지 않음 - 상단측(M3) — 차례 성공하면 uphill 세션 비움 · 종단 보기도 세션에서 읽음 - 확정 하위(M6) — 응답 failed_parts(비정규 측점 횡단 · 상단측 병합 · 종단 없음) · 화면이 던짐 - 시험 test_443_save_stops.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B05_Profile/B05_Profile_Api_Fetch.ts | 13 +- B05_Profile/B05_Profile_Router_Lifecycle.py | 10 +- B05_Profile/B05_Profile_Schema.py | 2 + B05_Profile/B05_Profile_UI_Page_Actions.ts | 48 +++-- B05_Profile/B05_Profile_UI_Page_Structures.ts | 18 +- resources/tester/helper_443_save_stops.cjs | 110 ++++++++++ resources/tester/test_443_save_stops.py | 201 ++++++++++++++++++ 7 files changed, 367 insertions(+), 35 deletions(-) create mode 100644 resources/tester/helper_443_save_stops.cjs create mode 100644 resources/tester/test_443_save_stops.py diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 05e7e1310..7f2f0a568 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -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 { .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 { + 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); } /** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */ diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 285cf5dd2..d49503b57 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -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( diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index d429a5aa9..c943f5628 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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): diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index f9f79c93d..deb03576f 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -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 · 옛 캐시 패치 길 지움) diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 7420aaaa1..8cf42e300 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -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("구조물 저장에 실패했습니다."); } } diff --git a/resources/tester/helper_443_save_stops.cjs b/resources/tester/helper_443_save_stops.cjs new file mode 100644 index 000000000..9031f6c45 --- /dev/null +++ b/resources/tester/helper_443_save_stops.cjs @@ -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); + }, +); diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py new file mode 100644 index 000000000..a0b6a2921 --- /dev/null +++ b/resources/tester/test_443_save_stops.py @@ -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 == []