- 데스크탑_메인 storage 사본 · DB 읽기 전용 · 재계산 체인은 성공만 돌려주는 가짜 - 추종 · 토공 최소 · 기울기 순한 · 비중 50/25/25 마다 /route/initial → 화면 코드로 [확인] 몸통 → /route/replan → /route/plan - 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드 · [초기화] = 마지막 초기값(캐시 없으면 서버가 같은 노드) · 비교값 다 있음 - 화면 셈은 helper_b05_routeedit_initial_apply.cjs 가 화면 코드 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
236 lines
9.5 KiB
Python
236 lines
9.5 KiB
Python
"""초기노선 갈래 저장 왕복 — [확인]에 실은 초기값이 다시 열기 뒤 그대로인가(계획서 20).
|
|
|
|
환경 배정 storage 프로젝트(데스크탑_메인)의 **사본**에서 화면과 같은 차례로 서버 길을 부른다.
|
|
갈래(추종 · 토공 최소 · 기울기 순한 · 비중 50/25/25)마다
|
|
① `POST /route/initial` → 비교값이 다 있음
|
|
② 화면 코드로 [확인] 몸통을 만들어 `POST /route/replan` → 서버가 initial 을 적음
|
|
③ `GET /route/plan` → 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드
|
|
④ [초기화] — 캐시가 있으면 그 노드, 없으면(다른 PC) 서버가 같은 갈래로 같은 노드를 냄
|
|
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
|
|
|
|
- 파일: 프로젝트 폴더를 임시 폴더로 복사(원본 입력 · 점군 · 큰 파일 제외) ·
|
|
`STORAGE_BASE_DIR` 를 그리로.
|
|
- DB: 읽기 전용 세션으로 진짜 DB 를 읽기만 함.
|
|
- 재계산 체인(`_recompute`, 배수유역부터 약 90초 · DB 에 씀)은 성공만 돌려주는 가짜로 갈음 —
|
|
여기서 보는 것은 초기값이 적히고 다시 읽히는 길이다.
|
|
- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
PROJECT_ID = "79da822d-921a-40f8-bb93-d8ffd90eeedf" # OWNERS.md — desktop-main `데스크탑_메인`
|
|
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
|
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section", "initial_snapshot")
|
|
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
|
HELPER = Path(__file__).with_name("helper_b05_routeedit_initial_apply.cjs")
|
|
|
|
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
|
|
|
LOGICS: list[tuple[str, dict[str, float] | None]] = [
|
|
("follow", None),
|
|
("earthwork", None),
|
|
("gentle", None),
|
|
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
|
|
]
|
|
|
|
|
|
def _copy_project(dest: Path) -> None:
|
|
for rel in _COPY_DIRS:
|
|
shutil.copytree(SOURCE / rel, dest / rel)
|
|
for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"):
|
|
(dest / rel).mkdir(parents=True, exist_ok=True)
|
|
for item in (SOURCE / rel).iterdir():
|
|
if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES:
|
|
shutil.copy2(item, dest / rel / item.name)
|
|
for item in SOURCE.iterdir():
|
|
if item.is_file():
|
|
shutil.copy2(item, dest / item.name)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def api(tmp_path_factory):
|
|
aiomysql = pytest.importorskip("aiomysql")
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
import B05_Profile.B05_Profile_Router_Replan as replan
|
|
import common_util.common_util_storage as storage
|
|
import config.config_db as config_db
|
|
from config import config_system as cfg
|
|
|
|
storage_root = tmp_path_factory.mktemp("initial") / "storage"
|
|
project_root = storage_root / "1" / "3" / PROJECT_ID
|
|
_copy_project(project_root)
|
|
patch = pytest.MonkeyPatch()
|
|
patch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
|
recomputed: list[str] = []
|
|
|
|
async def fake_recompute(project_id, root, stored_path):
|
|
recomputed.append(str(root))
|
|
return {"route_id": None, "total_length_m": None, "vertex_count": 0}
|
|
|
|
patch.setattr(replan, "_recompute", fake_recompute)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app):
|
|
try:
|
|
pool = await aiomysql.create_pool(
|
|
host=cfg.DB_HOST,
|
|
port=cfg.DB_PORT,
|
|
db=cfg.DB_NAME,
|
|
user=cfg.DB_USER,
|
|
password=cfg.DB_PASSWORD,
|
|
minsize=1,
|
|
maxsize=4,
|
|
autocommit=True,
|
|
charset="utf8mb4",
|
|
# 읽기 전용 세션 — 새는 쓰기가 있으면 DB 를 바꾸지 않고 터짐
|
|
init_command="SET time_zone='+00:00', SESSION tx_read_only=1",
|
|
)
|
|
except Exception as exc: # DB 가 안 닿는 곳
|
|
pytest.skip(f"DB 에 안 닿음: {exc}")
|
|
config_db.db_pool = pool
|
|
try:
|
|
yield
|
|
finally:
|
|
config_db.db_pool = None
|
|
pool.close()
|
|
await pool.wait_closed()
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
app.include_router(replan.router)
|
|
try:
|
|
with TestClient(app) as client:
|
|
client.root = project_root # type: ignore[attr-defined]
|
|
client.recomputed = recomputed # type: ignore[attr-defined]
|
|
yield client
|
|
finally:
|
|
patch.undo()
|
|
|
|
|
|
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
|
if shutil.which("node") is None:
|
|
pytest.skip("node 가 없음")
|
|
out = subprocess.run(
|
|
["node", str(HELPER)],
|
|
input=json.dumps({"jobs": jobs}),
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=120,
|
|
check=True,
|
|
)
|
|
return json.loads(out.stdout)
|
|
|
|
|
|
def _initial(api, logic: str, weights: dict[str, float] | None) -> dict[str, Any]:
|
|
body: dict[str, Any] = {"logic": logic}
|
|
if weights:
|
|
body["weights"] = weights
|
|
response = api.post(f"/api/projects/{PROJECT_ID}/route/initial", json=body)
|
|
assert response.status_code == 200, response.text
|
|
return response.json()
|
|
|
|
|
|
def _close(a: list[list[float]], b: list[list[float]], tol: float = 1e-3) -> bool:
|
|
return len(a) == len(b) and all(
|
|
abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol for p, q in zip(a, b)
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rounds(api):
|
|
"""갈래마다 ①~③ — 결과를 모아 두고 시험마다 나눠 본다."""
|
|
out = []
|
|
before = api.get(f"/api/projects/{PROJECT_ID}/route/plan").json()
|
|
for logic, weights in LOGICS:
|
|
answer = _initial(api, logic, weights)
|
|
[applied] = _screen(
|
|
[{"kind": "apply", "response": answer, "logic": logic, "weights": weights}]
|
|
)
|
|
confirmed = api.post(f"/api/projects/{PROJECT_ID}/route/replan", json=applied["body"])
|
|
assert confirmed.status_code == 200, confirmed.text
|
|
plan = api.get(f"/api/projects/{PROJECT_ID}/route/plan").json()
|
|
[reopened] = _screen([{"kind": "reopen", "plan": plan, "cached": applied["choice"]}])
|
|
out.append(
|
|
{
|
|
"logic": logic,
|
|
"weights": weights,
|
|
"answer": answer,
|
|
"applied": applied,
|
|
"confirmed": confirmed.json(),
|
|
"plan": plan,
|
|
"reopened": reopened,
|
|
}
|
|
)
|
|
return {"before": before, "rounds": out}
|
|
|
|
|
|
def test_원본은_처음_추종(rounds):
|
|
assert rounds["before"]["current_initial"]["logic"] == "follow"
|
|
|
|
|
|
def test_비교값이_응답에_다_있음(rounds):
|
|
for item in rounds["rounds"]:
|
|
metrics = item["answer"]["metrics"]
|
|
for key in ("length_m", "max_grade_pct", "avg_grade_pct", "cut_m3", "fill_m3"):
|
|
assert isinstance(metrics[key], (int, float)), (item["logic"], key)
|
|
assert isinstance(metrics["over_limit"]["total"], int)
|
|
assert isinstance(metrics["offset_avg_m"], (int, float))
|
|
# 화면 한 줄에 빈 칸(—)이 없다.
|
|
assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"]
|
|
|
|
|
|
def test_확인하면_서버가_초기값을_적고_다시_열면_그대로(rounds, api):
|
|
assert len(api.recomputed) == len(LOGICS)
|
|
for item in rounds["rounds"]:
|
|
sent = item["applied"]["body"]["initial"]
|
|
assert sent["mode"] == item["logic"]
|
|
assert item["confirmed"]["current_initial"]["logic"] == item["logic"]
|
|
current = item["plan"]["current_initial"]
|
|
assert current["logic"] == item["logic"]
|
|
if item["weights"]:
|
|
assert {k: round(v) for k, v in current["weights"].items()} == item["weights"]
|
|
reopened = item["reopened"]
|
|
assert reopened["base"]["logic"] == item["logic"]
|
|
assert reopened["label"] == item["applied"]["label"]
|
|
assert reopened["sameAsCache"] is True # [초기화]가 캐시 노드를 씀
|
|
saved = json.loads(
|
|
(api.root / "B05_Profile" / "route" / "initial_choice.json").read_text("utf-8")
|
|
)
|
|
assert saved["logic"] == "weighted"
|
|
|
|
|
|
def test_다시_연_노선은_확인한_노선(rounds):
|
|
for item in rounds["rounds"]:
|
|
sent = [[v["x"], v["y"]] for v in item["applied"]["body"]["vertices"]]
|
|
assert _close(item["reopened"]["planned"], sent), item["logic"]
|
|
|
|
|
|
def test_초기화는_마지막_초기값으로(rounds, api):
|
|
last = rounds["rounds"][-1]
|
|
cached = last["applied"]["choice"]["snapshot"]["planned"]
|
|
# 캐시가 있으면 그 노드 — 마지막 적용(비중 50/25/25).
|
|
assert _close(cached, [[v["x"], v["y"]] for v in last["applied"]["body"]["vertices"]])
|
|
# 캐시가 없으면(다른 PC) 서버가 적어 둔 갈래 · 비중으로 다시 물음 → 같은 노드.
|
|
base = last["reopened"]["base"]
|
|
again = _initial(api, base["logic"], base["weights"])
|
|
[redo] = _screen(
|
|
[{"kind": "apply", "response": again, "logic": base["logic"], "weights": base["weights"]}]
|
|
)
|
|
assert _close(redo["choice"]["snapshot"]["planned"], cached)
|
|
# 다른 갈래들과는 다른 노선이다(되돌림이 실제로 갈래를 가림).
|
|
others = [r["applied"]["choice"]["snapshot"]["planned"] for r in rounds["rounds"][:-1]]
|
|
assert all(not _close(o, cached) for o in others)
|