Files
Aislo/resources/tester/test_472_6_route_save.py
T
eomsangdonandClaude Opus 5.5 21b8c3231b fix(493-1 손 고침 저장 = 화면 그대로): 노선 손 고침 [저장] 초기화 갈래 걷음 · 시설 따라감 되살림 · 추천 [적용] 500 고침
- 손 고침 [저장](교점 끌기 · 더함 · 지움 · R · /design/save) = 492 이전 따라감 — 구조물 · 관(옵션) · 추가 측점 지킴 · 새 세류선 교차 관만 더함 · 초기값 그대로(요구 289 · 290)
- 추천 [적용](as_initial) = 초기화 + 초기값 다시 뜸 그대로 · 응답 route_reset 은 [적용]에만
- route_shape_changed 걷음(TS 짝 걷기 = sub1) · 한 본문 초기화 갈래 · 초기값 사본 떠 두기 걷음
- [적용] 500 = 떠 두기 중 뒤 추천 미리 셈이 _derived 파일을 지움(FileNotFoundError) → 떠 두기 · 되돌림이 사라진 파일 건너뜀
- 시험: test_283_route_change_reset 고쳐 씀(손 고침 = 따라감 · [적용] = 초기화 · 사라진 파일) · 492 때 고친 따라감 시험 원래 기대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:48:52 +09:00

487 lines
20 KiB
Python

"""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).
검증 프로젝트가 없는 자리(다른 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 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
SHARED_ROOT = _db_ready()
#: 시험이 쓰는 뿌리 = 공유 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 시험 — 검증 프로젝트 없으면 건너뜀."""
return pytest.mark.skipif(SHARED_ROOT is None, reason="검증_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"(?<!\d)0*({numbers})(?!\d)")
keep = {}
for table, values in rows.items():
out = []
for row in values:
row = {k: v for k, v in row.items() if k not in ("id", "route_id", "computed_at")}
out.append(rename.sub("N", json.dumps(row, sort_keys=True, ensure_ascii=False)))
keep[table] = sorted(out)
return keep, {rename.sub("N", k): v for k, v in files.items()}
@live
def test_정상_한_본문은_옛_차례와_같음() -> 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 # 버린 차례 = 초안 전 값이 있을 때만 되씀