"""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") # 기본 창구와 같은 본문(463 · 472-6 기본 조각) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄) assert "body: base.body," in actions and "flushRouteParts" not in actions fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8") assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch 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 == []