"""PLAN 472-6 6-B — 노선 바뀐 [저장]도 한 본문 · 다시 얹기 서버 Node(같은 TS). ① 넘김 — 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` → `saveCachedCrossPatches` 차례로) 결과 == 서버 Node `stations.carry`(`routeCarryPatches`) 결과(patch · 버린 수) · 손 상단측 안 넘김(요구 274). ② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) == Node `facility.rebase`. ③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름. ④ 실제 창구(검증_sub8 DB · storage 는 임시 사본) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) = 노선 행 · 자식 표 · storage(초기값 사본 포함) 그대로 · 정상 한 본문(교점 끌기) = 옛 차례 (노선 저장 → 다시 얹기 → 횡단 저장)와 행 · 파일 같음 · 시설 수 그대로 · 초기값 그대로(493). 실DB(④)는 `AISLO_LIVE_DB=1` 일 때만 · 검증 프로젝트가 없는 자리(다른 PC · DB 없음)도 건너뜀. ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q """ from __future__ import annotations import asyncio import json import os import re import shutil import subprocess import sys from pathlib import Path from uuid import UUID import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(HERE)) from common_util import common_util_save_guard as guard # noqa: E402 from common_util.common_util_calc_runner import run_calc # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치", ) def _node(*args: str, timeout: int = 300) -> str: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout ) assert proc.returncode == 0, proc.stderr return proc.stdout @pytest.fixture(scope="module") def out(tmp_path_factory) -> dict: """vite ssr 로 진짜 모듈을 묶어 옛 길을 돌림(417 꼴).""" work = tmp_path_factory.mktemp("r6b") _node( str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), "build", "--configLoader", "runner", "--ssr", "../resources/tester/helper_472_6_route_entry.ts", "--outDir", str(work), "--logLevel", "error", ) bundle = work / "bundle.mjs" shutil.copy(work / "helper_472_6_route_entry.js", bundle) return json.loads(_node(str(HERE / "helper_472_6_route_carry.mjs"), str(bundle))) @pytest.fixture(scope="module") def carried(out) -> dict: inputs = { **out["carry"], "detail": out["detail"], "pipes_before": out["pipesBefore"], "pipes_after": out["pipesAfter"], } result = run_calc(["stations.carry"], {"carry": inputs}) assert result is not None return result["stations.carry"] def _by_chainage(patches: list[dict]) -> dict[str, dict]: return {f"{patch['chainage_m']:.2f}": patch for patch in patches} def test_넘김_옛_브라우저_길과_서버_Node_같음(out, carried) -> None: assert carried["patches"] == out["oldPatches"] assert carried["outside"] == out["oldOutside"] def test_넘김_값_사용자_칸(out, carried) -> None: """같은 값이 뜻있는지 — 카드 선택 · 사면 쉬프트 · 암선 · 암 경사(MC 옮김) · 관 조절 · 메모가 새 측점에.""" by = _by_chainage(carried["patches"]) assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True assert by["20.00"]["ditch_type"] == "u_flume" # 손 단면 유형 = 자리 칸(475-3) — 이 목록은 자리를 몰라(옛 목록) 같은 자리로 봄 · 넘김 # (옛 단언 = 늘 새 지형) assert by["20.00"]["section_mode"] == "left_cut" assert by["20.00"]["structure_note"] == "앞 메모" assert by["40.00"]["shoulder_extend_left_m"] == 0.5 assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만) assert by["60.00"]["display_half_width_m"] == 6 # 세션 폭이 저장분(5) 위에 assert by["103.00"]["rock_boundary_offset_m"] == 1.5 assert by["112.00"]["cut_slope_ratio_user"] == 0.8 # MC 110 → 112(곡선 열쇠 옮김) pipe = by["83.00"] assert pipe["revet_adjust"]["outlet"]["x"] == 0.3 # 관 따라 80 → 83 assert pipe["revet_adjust"]["inlet"]["h"] == 1.8 # 관 옵션 벽 높이(저장 관 · 새 자리) assert pipe["basin_adjust"]["depth_m"] == 1.2 and pipe["detail_table"] == {"rows": 2} assert pipe["revet_link_detached"] is True assert not any( p.get("structure_reset") for p in carried["patches"] ) # 일반 측점 종류 바꿈 = 버림 assert carried["outside"] == out["oldOutside"] >= 4 # 구조물 130 고침 셋 · 40 옹벽 · 60 종류 def test_손_상단측은_안_넘김_요구274(out) -> None: assert "uphill" not in out["carry"]["states"] assert out["carry"]["states"]["kindreset"] == {"60.00": "culvert"} def test_다시_얹기_옛_함수와_서버_Node_같음(out) -> None: result = run_calc(["facility.rebase"], {"rebase": out["rebaseInput"]}) assert result is not None assert result["facility.rebase"] == out["oldRebase"] pipes = {pipe["id"]: pipe["chainage_m"] for pipe in result["facility.rebase"]["pipes"]} assert pipes == {"p80": 83, "p20": 25, "pnew": 120} # 따라감 · 사용자 옮김 그대로 · 새 교차 관 assert result["facility.rebase"]["structures"][0]["chainage_m"] == 63 def test_옛_코드_실패_한_본문이_노선을_받음() -> None: from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest request = DesignSaveRequest.model_validate( { "route_id": 7, "route": {"draft": {"nodes": [[0, 0], [10, 0]]}}, "carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None}, } ) assert request.route is not None and request.carry is not None save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") assert "saveRouteDraft" not in save and "carryAfterRouteSave" not in save assert re.search(r"postDesignSave\([^;]*route \? \{ route, carry", save, re.S) apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") assert "export async function carryAfterRouteSave" not in apply # ── 실제 창구 ────────────────────────────────────────────────────────────── PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md) VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at") def _db_ready() -> Path | None: try: from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_storage import resolve_stored_project_path from config.config_db import close_db_pool, init_db_pool async def find(): pool = await init_db_pool() try: async with pool.acquire() as connection: return await get_project_storage_relative_path(connection, UUID(PROJECT)) finally: await close_db_pool() root = Path(resolve_stored_project_path(asyncio.run(find()))) return root if (root / "B05_Profile" / "route").is_dir() else None except Exception: return None #: 켤 때만 — `AISLO_LIVE_DB=1`(공유 검증 DB 행을 실제로 씀 · 기본 건너뜀 · DB 도 안 열음) SHARED_ROOT = _db_ready() if os.environ.get("AISLO_LIVE_DB") == "1" else None #: 시험이 쓰는 뿌리 = 공유 storage 의 임시 사본(492 · `sandbox` 가 세움 · Synology 로 안 번지게) ROOT_DIR: Path | None = None @pytest.fixture(scope="module") def sandbox(): global ROOT_DIR from helper_temp_storage import temp_project_storage with temp_project_storage(SHARED_ROOT) as root: ROOT_DIR = root yield root ROOT_DIR = None def live(test): """실DB · 사본 storage 시험 — `AISLO_LIVE_DB=1` 이고 검증 프로젝트가 있을 때만.""" return pytest.mark.skipif( SHARED_ROOT is None, reason="AISLO_LIVE_DB=1 일 때만(검증_sub8 · DB 필요)" )(pytest.mark.usefixtures("sandbox")(test)) def _rows() -> dict: from config.config_db import close_db_pool, init_db_pool async def run(): pool = await init_db_pool() try: async with pool.acquire() as connection: return await guard.snapshot_rows(connection, PROJECT, surface=True) finally: await close_db_pool() return asyncio.run(run()) def _state(root: Path) -> tuple[dict, dict]: """(행 — 시각 칸 뺌, 파일 내용 해시) — 노선 바뀐 저장 범위 전부.""" import hashlib clean = { table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values] for table, values in _rows().items() } files = { relative: hashlib.sha1(path.read_bytes()).hexdigest() for relative, path in guard._files( root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE ).items() if not relative.startswith("_derived/") } return clean, files def _plan_draft(shape: bool = True) -> dict: """지금 계획노선 노드 · 넣은 편집값 그대로 + `shape` 면 가운데 노드 하나를 3 m 옆으로(노선 끌기) · 아니면 측점 스냅만 뒤집음(모양 그대로).""" from B05_Profile.B05_Profile_Router_Replan import read_route_plan from config.config_db import close_db_pool, init_db_pool async def run(): await init_db_pool() try: return await read_route_plan(UUID(PROJECT)) finally: await close_db_pool() plan = asyncio.run(run()) from B05_Profile.B05_Profile_Engine_InitialProfile import draft_nodes edits = plan.get("node_edits") if edits: # 넣은 값 그대로(화면 `flattenServerPlan` 이 읽는 것) points = [[float(n["x"]), float(n["y"])] for n in plan["nodes"]] flags, radii = [bool(e["curve"]) for e in edits], [e["radius_m"] for e in edits] else: # 편집값 없는 노선 = 화면 펴기(그린 R) 짝 xy, flags, radii = draft_nodes(plan["nodes"], plan["curves"]) points = [[x, y] for x, y in xy] if shape: points[len(points) // 2][1] += 3.0 snap = bool(plan.get("station_snap")) return { "rev": 1, "nodes": points, "curveOn": flags, "curveRadius": radii, "curveLock": [e.get("lock") for e in edits] if edits else [None] * len(points), "curveArc": [e.get("arc_m") for e in edits] if edits else [None] * len(points), "stationSnap": snap if shape else not snap, "stationSnapStep": plan.get("station_snap_step"), } def _save(body: dict): from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest, save_design from config.config_db import close_db_pool, init_db_pool async def run(): await init_db_pool() try: return await save_design(UUID(PROJECT), DesignSaveRequest.model_validate(body), None) finally: await close_db_pool() response = asyncio.run(run()) return response.status_code, json.loads(bytes(response.body).decode("utf-8")) def _latest_route_id() -> int: return max(int(row["id"]) for row in _rows()["routes"]) def _body(draft: dict, **extra) -> dict: return { "route_id": _latest_route_id(), "route": {"draft": draft}, "carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None}, "cross": {}, **extra, } @live def test_노선_차례_뒤_실패면_노선_행_storage_그대로(monkeypatch) -> None: before = _state(ROOT_DIR) monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "route") status, body = _save(_body(_plan_draft())) assert status == 409 and body["step"] == "route" and body["restored"] is True, body assert _state(ROOT_DIR) == before # 새 노선 행 · 자식 표 · 노선 파일 · 관 · 구조물 그대로 @live def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None: from B06_Section import B06_Section_Router_DesignSave_Route as moving before = _state(ROOT_DIR) def broken(ids, inputs): if ids == ["stations.carry"]: raise RuntimeError("넘김 일부러 실패") return run_calc(ids, inputs) monkeypatch.setattr(moving, "run_calc", broken) status, body = _save(_body(_plan_draft())) assert status == 409 and body["step"] == "cross" and body["restored"] is True, body assert "넘김 일부러 실패" in body["message"] assert _state(ROOT_DIR) == before def _normalized(state: tuple[dict, dict], new_ids: set[int]) -> tuple[dict, dict]: """새 노선 번호 · 행 id · 계산한 때(`computed_at`)를 지운 비교 꼴 — 옛 길 · 새 길이 다른 자동 번호 · 시각을 받음.""" rows, files = state numbers = "|".join(str(i) for i in sorted(new_ids, reverse=True)) or "x" # 3D 파일 = 0 채운 번호(`corridor_0444.json`) rename = re.compile(rf"(? None: from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Router_Replan import replan_route from B06_Section import B06_Section_Router_DesignSave_Route as moving from B06_Section.B06_Section_Router_Confirm import save_sections from B06_Section.B06_Section_Schema import SectionConfirmRequest from config.config_db import close_db_pool, get_db_pool, init_db_pool start = _state(ROOT_DIR) draft = _plan_draft() old_ids = {int(row["id"]) for row in _rows()["routes"]} async def old_way(): """옛 브라우저 차례 — 노선 저장(`/route/replan`) → 횡단 넘김 patch → 횡단 저장.""" await init_db_pool() try: async with get_db_pool().acquire() as connection: rows = await guard.snapshot_rows(connection, PROJECT, surface=True) files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE) before = await moving.saved_facilities(UUID(PROJECT)) saved = await replan_route( UUID(PROJECT), RouteSaveRequest.model_validate({"draft": draft}) ) carried = await moving.carry_cross(UUID(PROJECT), int(saved["route_id"]), None, before) await save_sections( UUID(PROJECT), int(saved["route_id"]), SectionConfirmRequest.model_validate({"cross_patches": carried["patches"] or None}), ) return rows, files finally: await close_db_pool() rows, files = asyncio.run(old_way()) old = _state(ROOT_DIR) async def undo(): await init_db_pool() try: files.restore() async with get_db_pool().acquire() as connection: await guard.restore_rows(connection, PROJECT, rows) finally: await close_db_pool() asyncio.run(undo()) assert _state(ROOT_DIR) == start try: status, body = _save(_body(draft)) assert status == 200 and body["status"] == "success", body assert set(body["steps"]) == {"route", "carry", "cross"} new = _state(ROOT_DIR) ids = {int(row["id"]) for row in _rows()["routes"]} | { int(row["id"]) for row in json.loads(json.dumps(old[0]["routes"])) } ids -= old_ids (rows_new, files_new), (rows_old, files_old) = _normalized(new, ids), _normalized(old, ids) assert [t for t in rows_new if rows_new[t] != rows_old.get(t)] == [] assert ( sorted( k for k in files_new.keys() | files_old.keys() if files_new.get(k) != files_old.get(k) ) == [] ) finally: asyncio.run(undo()) files.discard() assert _state(ROOT_DIR) == start @live def test_모양_바뀐_노선은_시설_따라감_초기값_그대로() -> None: """493 · 요구 289 · 290 — 교점 3 m 끌기 [저장] = 보이는 화면 그대로(구조물 · 관 · 추가 측점 수 같음 · 관 id 그대로) · 초기화 · 초기값 다시 뜸 없음 · 끝에 되돌림.""" from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations from B05_Profile.B05_Profile_Structures_Repository import load_structures from config.config_db import close_db_pool, get_db_pool, init_db_pool start = _state(ROOT_DIR) pipes = ROOT_DIR / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" def facilities() -> tuple[int, set, int]: points = json.loads(pipes.read_text("utf-8"))["points"] if pipes.is_file() else [] return ( len(load_structures(str(ROOT_DIR))[1]), {p.get("id") for p in points}, len(load_plus_stations(ROOT_DIR)), ) async def hold(): await init_db_pool() try: async with get_db_pool().acquire() as connection: return await guard.snapshot_rows(connection, PROJECT, surface=True) finally: await close_db_pool() rows = asyncio.run(hold()) files = guard.snapshot_storage( ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE ) structures, pipe_ids, plus = facilities() initial = {k: v for k, v in start[1].items() if k.startswith("initial_snapshot/")} try: status, body = _save(_body(_plan_draft())) assert status == 200 and {"route", "carry", "cross"} <= set(body["steps"]), body route = body["steps"]["route"] assert "route_reset" not in route and route["initial_refreshed"] is False after_structures, after_ids, after_plus = facilities() assert (after_structures, after_plus) == (structures, plus) assert pipe_ids <= after_ids # 사용자 · 옛 관 그대로 · 새 교차 관만 더함 now = {k: v for k, v in _state(ROOT_DIR)[1].items() if k.startswith("initial_snapshot/")} assert now == initial # 초기값 그대로 finally: async def undo(): await init_db_pool() try: files.restore() async with get_db_pool().acquire() as connection: await guard.restore_rows(connection, PROJECT, rows) finally: await close_db_pool() asyncio.run(undo()) files.discard() assert _state(ROOT_DIR) == start def test_저장한_노선_초안은_버림으로_되돌리지_않음() -> None: """화면 흠(6-B 화면 검증) — 저장 뒤 노선 초안 칸을 비우면 묶음이 「초안 버림」으로 보고 초안 전 관 · 구조물 초안을 세션에 되써 다음 [저장]이 옛 자리로 되돌렸음 → 저장 성공이면 초안 전 값을 버림(commit).""" apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") body = apply[apply.index("export function routeSaved") :] body = body[: body.index("\n}\n")] assert body.index("commitRouteFacilities(projectId);") < body.index('set("routeDraft", null)') page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8") assert "commit: () => void (beforeDraft = null)," in page assert "if (!beforeDraft) return;" in page # 버린 차례 = 초안 전 값이 있을 때만 되씀