test(B05·B06): 저장 왕복 시험 — 고른 값이 저장 · 다시 읽기 뒤 그대로인지 0 차이 대조
- test_b05_b06_save_roundtrip.py: 배정 storage 프로젝트(랩탑_보조) 사본에서 B05 [임시저장] · B06 [저장] 차례대로 서버 API 호출(관 → 계획선 → 구조물 → 확정 → 구간값 → 횡단) · pipe_points.json · structures.json · 종단 파일 · 응답 · DB 로 가는 횡단 patch 를 고른 값과 대조 · 다시 열어 그대로 저장해도 안 바뀌는지 - DB 는 읽기 전용 세션으로 읽기만 · 쓰는 함수는 받은 값을 적는 가짜 · 원본 · DB 안 바뀜 - helper_b05_save_payload.mjs: 저장 본문을 화면 코드 그대로(시설 되붙이기 · 구조물 측점 칸 · 구간값 · 이동 예약 흘려보내기) node 로 만듦 - 지금 코드에서 잡힌 것: 옛 관 이동 예약 적용 · 측점 0.1m 재읽기(관 · 구조물) · 새 관이 이웃 관 속성을 가져감 · 관 측점 격자 붙이기 · B06 저장에서 옮긴 관 측점 횡단 patch 버려짐 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
||||
// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[])
|
||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? ".");
|
||||
const { build } = await import(
|
||||
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
|
||||
);
|
||||
|
||||
// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다
|
||||
class Node {
|
||||
constructor() {
|
||||
this.value = "";
|
||||
this.children = [];
|
||||
this.classList = { toggle() {}, add() {}, remove() {} };
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.children.push(kid);
|
||||
return kid;
|
||||
}
|
||||
addEventListener() {}
|
||||
setAttribute() {}
|
||||
}
|
||||
const session = new Map();
|
||||
globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() };
|
||||
globalThis.window = {
|
||||
sessionStorage: {
|
||||
getItem: (k) => (session.has(k) ? session.get(k) : null),
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
},
|
||||
location: { origin: "http://localhost" },
|
||||
setTimeout: (...a) => setTimeout(...a),
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
};
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
globalThis.localStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-"));
|
||||
try {
|
||||
await build({
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
root,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ui": path.join(root, "ui_template"),
|
||||
"@config": path.join(root, "config"),
|
||||
"@util": path.join(root, "common_util"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
lib: {
|
||||
entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "entry",
|
||||
},
|
||||
},
|
||||
});
|
||||
const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf-8"));
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
if (job.op === "attach") {
|
||||
const store = mod.createFacilityStore();
|
||||
store.replaceFromResponse(job.entries);
|
||||
results.push(store.attach(job.points));
|
||||
} else if (job.op === "station") {
|
||||
const fields = mod.stationFields("측점", () => job.interval);
|
||||
fields.write(job.chainage, job.interval);
|
||||
results.push(fields.read(job.interval, true));
|
||||
} else if (job.op === "culvert") {
|
||||
session.clear();
|
||||
session.set("opt", JSON.stringify(job.pending ?? {}));
|
||||
session.set("move", JSON.stringify(job.moves ?? {}));
|
||||
let sent = null;
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
const body = init.method === "PUT" ? JSON.parse(init.body) : null;
|
||||
if (body) sent = body.points;
|
||||
const payload = body ? { pipe_points: body.points } : { pipe_points: job.server };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
await mod.flushCulvertOptions("p", "opt", "move");
|
||||
results.push(sent);
|
||||
} else {
|
||||
throw new Error(`모르는 작업 ${job.op}`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* =============================================================================
|
||||
* helper_b05_save_payload_entry.ts
|
||||
* 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때
|
||||
* 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기.
|
||||
* ========================================================================== */
|
||||
|
||||
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
|
||||
export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
@@ -0,0 +1,499 @@
|
||||
"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장).
|
||||
|
||||
환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를
|
||||
부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과
|
||||
0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 ·
|
||||
구조물 측점 칸 · 구간값 예약).
|
||||
|
||||
- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림.
|
||||
- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 ·
|
||||
쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(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 = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
|
||||
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
||||
# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀
|
||||
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section")
|
||||
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""DB 에 쓰는 함수 대신 — 받은 값을 적어 둠."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def fake(self, name: str, result: Any = None):
|
||||
async def _fake(*args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, {"args": args[1:], **kwargs}))
|
||||
return result
|
||||
|
||||
return _fake
|
||||
|
||||
def of(self, name: str) -> list[dict[str, Any]]:
|
||||
return [call for key, call in self.calls if key == name]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(tmp_path, monkeypatch):
|
||||
aiomysql = pytest.importorskip("aiomysql")
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
|
||||
import B05_Profile.B05_Profile_Router as route
|
||||
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures
|
||||
import B06_Section.B06_Section_Repository_Bulk as bulk
|
||||
import B06_Section.B06_Section_Router_Confirm as sections
|
||||
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 / "storage"
|
||||
_copy_project(storage_root / "1" / "3" / PROJECT_ID)
|
||||
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
||||
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary"))
|
||||
monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True))
|
||||
monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options"))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data"))
|
||||
monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections"))
|
||||
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
|
||||
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute"))
|
||||
real_known = sections.get_cross_section_chainages
|
||||
|
||||
async def spy_known(connection, route_id):
|
||||
rows = await real_known(connection, route_id)
|
||||
rec.calls.append(("known", {"chainages": list(rows)}))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known)
|
||||
|
||||
@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=6,
|
||||
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)
|
||||
for module in (basins, route, lifecycle, structures, sections):
|
||||
app.include_router(module.router)
|
||||
with TestClient(app) as client:
|
||||
client.rec = rec # type: ignore[attr-defined]
|
||||
client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined]
|
||||
yield client
|
||||
|
||||
|
||||
# ── 화면 코드 그대로 본문 만들기(node) ────────────────────────────────────────
|
||||
HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs")
|
||||
STATION_INTERVAL_M = 20.0
|
||||
# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김
|
||||
# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`)
|
||||
PIPE_MOVE_THRESHOLD_M = 0.005
|
||||
|
||||
|
||||
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
out = subprocess.run(
|
||||
["node", str(HELPER), str(ROOT)],
|
||||
input=json.dumps(jobs),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# ── 비교 모양 ─────────────────────────────────────────────────────────────────
|
||||
LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json"
|
||||
PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json"
|
||||
STRUCTURES_FILE = "B05_Profile/route/structures.json"
|
||||
|
||||
|
||||
def _key(chainage: float) -> str:
|
||||
return f"{chainage:.2f}"
|
||||
|
||||
|
||||
def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌)."""
|
||||
return {
|
||||
_key(p["chainage_m"]): {
|
||||
"facility": p.get("facility", "pipe"),
|
||||
"span": (p.get("start_m"), p.get("end_m")),
|
||||
"options": p.get("options") or {},
|
||||
}
|
||||
for p in points
|
||||
}
|
||||
|
||||
|
||||
def _read(root: Path, rel: str) -> Any:
|
||||
return json.loads((root / rel).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _strip(point: dict[str, Any]) -> dict[str, Any]:
|
||||
"""화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`)."""
|
||||
return {k: v for k, v in point.items() if k != "lonlat"}
|
||||
|
||||
|
||||
class Flow:
|
||||
"""화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]).
|
||||
|
||||
사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤
|
||||
다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름.
|
||||
"""
|
||||
|
||||
def __init__(self, api) -> None:
|
||||
self.api = api
|
||||
self.base = f"/api/projects/{PROJECT_ID}"
|
||||
latest = api.get(f"{self.base}/route/latest").json()
|
||||
self.route_id = latest["route"]["id"]
|
||||
self.surface = latest["surface_params"]
|
||||
self.model_id = latest["route"]["surface_model_id"]
|
||||
self.diffs: list[str] = []
|
||||
self.sent_irregular: list[float] = []
|
||||
self._plan()
|
||||
|
||||
def server_pipes(self) -> list[dict[str, Any]]:
|
||||
got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
return [_strip(p) for p in got]
|
||||
|
||||
# ── 사용자가 고른 값 ──
|
||||
def _plan(self) -> None:
|
||||
pipes = self.server_pipes()
|
||||
if len(pipes) < 9:
|
||||
pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요")
|
||||
self.pipes0 = pipes
|
||||
ch = [p["chainage_m"] for p in pipes]
|
||||
self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함
|
||||
self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로
|
||||
self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약
|
||||
self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약
|
||||
self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관)
|
||||
want = []
|
||||
for p in pipes:
|
||||
item = dict(p)
|
||||
if item["chainage_m"] == self.pipe_move[0]:
|
||||
item.update(chainage_m=self.pipe_move[1], source="user")
|
||||
elif item["chainage_m"] == self.pipe_option[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]}
|
||||
elif item["chainage_m"] == self.culvert[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.culvert[1]}
|
||||
want.append(item)
|
||||
want.append({"chainage_m": self.pipe_new, "source": "user"})
|
||||
self.want_pipes = sorted(want, key=lambda x: x["chainage_m"])
|
||||
|
||||
got = self.api.get(f"{self.base}/route/structures").json()
|
||||
self.revision0, items = got["revision"], got["structures"]
|
||||
spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None]
|
||||
if len(spans) < 3:
|
||||
pytest.skip("구간 구조물이 3 개 넘게 필요")
|
||||
moved, optioned, memoed = spans[0], spans[1], spans[2]
|
||||
width = moved["end_m"] - moved["start_m"]
|
||||
to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음
|
||||
self.struct_move = (moved["structure_id"], to, round(to + width, 2))
|
||||
self.want_structures = json.loads(json.dumps(items))
|
||||
for s in self.want_structures:
|
||||
if s["structure_id"] == moved["structure_id"]:
|
||||
s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2))
|
||||
s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침
|
||||
elif s["structure_id"] == optioned["structure_id"]:
|
||||
s["options"] = {**s["options"], "height_m": 3}
|
||||
s["memo"] = "왕복 시험 메모"
|
||||
elif s["structure_id"] == memoed["structure_id"]:
|
||||
s["memo"] = "메모만 바꿈"
|
||||
|
||||
lon = _read(self.api.root, LONGITUDINAL)
|
||||
grid = [s for s in lon["stations"] if s.get("kind") == "regular"]
|
||||
g0, g1 = grid[4], grid[len(grid) // 2]
|
||||
self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2}
|
||||
side = grid[3].get("uphill_side")
|
||||
self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right")
|
||||
self.cross_patches = [
|
||||
{
|
||||
"chainage_m": g0["chainage_m"],
|
||||
"rock_boundary_offset_m": -0.8,
|
||||
"cut_slope_ratio_user": 0.5,
|
||||
},
|
||||
{"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0},
|
||||
]
|
||||
|
||||
# ── 화면 코드가 만드는 본문 ──
|
||||
def b05_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`."""
|
||||
(station,) = _screen(
|
||||
[{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}]
|
||||
)
|
||||
entries = [dict(p) for p in self.pipes0]
|
||||
screen_ch = {}
|
||||
for p in self.want_pipes:
|
||||
c = p["chainage_m"]
|
||||
if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M:
|
||||
c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김
|
||||
screen_ch[p["chainage_m"]] = c
|
||||
for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감
|
||||
if e["chainage_m"] == self.pipe_option[0]:
|
||||
e["chainage_m"] = screen_ch[self.pipe_option[0]]
|
||||
e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]}
|
||||
points = [
|
||||
{
|
||||
"chainage_m": screen_ch[p["chainage_m"]],
|
||||
"source": "confirmed" if p.get("source") == "user" else p.get("source", "user"),
|
||||
}
|
||||
for p in self.want_pipes
|
||||
]
|
||||
(attached,) = _screen([{"op": "attach", "entries": entries, "points": points}])
|
||||
return attached
|
||||
|
||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감)."""
|
||||
before = {p["chainage_m"]: p.get("options") for p in self.pipes0}
|
||||
out = []
|
||||
for p in self.want_pipes:
|
||||
item = dict(p)
|
||||
if p["chainage_m"] == self.culvert[0]:
|
||||
item.pop("options", None)
|
||||
if before[self.culvert[0]]:
|
||||
item["options"] = before[self.culvert[0]]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]:
|
||||
"""구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음."""
|
||||
items = json.loads(json.dumps(self.want_structures))
|
||||
if not reread:
|
||||
return items
|
||||
sid, start, end = self.struct_move
|
||||
ch = next(s for s in items if s["structure_id"] == sid)
|
||||
a, b, c = _screen(
|
||||
[
|
||||
{"op": "station", "chainage": v, "interval": STATION_INTERVAL_M}
|
||||
for v in (start, ch["chainage_m"], end)
|
||||
]
|
||||
)
|
||||
ch.update(start_m=a, chainage_m=b, end_m=c)
|
||||
return items
|
||||
|
||||
def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음."""
|
||||
pending = {_key(self.culvert[0]): self.culvert[1]}
|
||||
moves = {_key(self.stale_move[0]): self.stale_move[1]}
|
||||
(body,) = _screen(
|
||||
[{"op": "culvert", "server": current, "pending": pending, "moves": moves}]
|
||||
)
|
||||
return body
|
||||
|
||||
# ── API(화면 Api_Fetch 와 같은 경로 · 본문) ──
|
||||
def put_pipes(self, pipes: list[dict[str, Any]]) -> None:
|
||||
res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def flush_culvert(self) -> None:
|
||||
body = self.culvert_payload(
|
||||
self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
)
|
||||
if body is not None:
|
||||
self.put_pipes(body)
|
||||
|
||||
def put_alignment(self) -> None:
|
||||
body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}}
|
||||
res = self.api.put(f"{self.base}/route/profile-alignment", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def put_structures(self, items: list[dict[str, Any]]) -> None:
|
||||
revision = self.api.get(f"{self.base}/route/structures").json()["revision"]
|
||||
body = {"base_revision": revision, "structures": items}
|
||||
res = self.api.put(f"{self.base}/route/structures", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def confirm(self, *, with_irregular: bool) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}]
|
||||
}
|
||||
if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물
|
||||
self.sent_irregular = [
|
||||
p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement"
|
||||
]
|
||||
body.update(
|
||||
filter_key=self.surface["source_filter"],
|
||||
method=self.surface["method"],
|
||||
smooth=self.surface["smooth"],
|
||||
surface_model_id=self.model_id,
|
||||
irregular_stations=[
|
||||
{"chainage_m": c, "structure": "배관"} for c in self.sent_irregular
|
||||
],
|
||||
)
|
||||
res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def save_sections(self) -> None:
|
||||
body = {"cross_patches": self.cross_patches}
|
||||
res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
# ── 다시 읽기 · 대조(사용자가 고른 값 기준) ──
|
||||
def check(self) -> None:
|
||||
root, d = self.api.root, self.diffs
|
||||
want = _pipes_view(self.want_pipes)
|
||||
for where, got in (
|
||||
("응답", _pipes_view(self.server_pipes())),
|
||||
("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])),
|
||||
):
|
||||
for k in sorted(set(want) | set(got), key=float):
|
||||
if want.get(k) != got.get(k):
|
||||
d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}")
|
||||
saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]}
|
||||
for s in self.want_structures:
|
||||
got_s = saved.get(s["structure_id"], {})
|
||||
for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"):
|
||||
if s.get(field) != got_s.get(field):
|
||||
d.append(
|
||||
f"구조물 {s['type_id']} {field} — "
|
||||
f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}"
|
||||
)
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {}
|
||||
if edits != self.offsets:
|
||||
d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}")
|
||||
up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {})
|
||||
if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"):
|
||||
d.append(
|
||||
f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · "
|
||||
f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}"
|
||||
)
|
||||
for c in self.sent_irregular:
|
||||
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
|
||||
if not any(abs(n - c) < 0.005 for n in near):
|
||||
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
|
||||
self._check_designs()
|
||||
|
||||
def _check_designs(self) -> None:
|
||||
"""횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림)."""
|
||||
rec = self.api.rec
|
||||
known = [c for call in rec.of("known") for c in call["chainages"]]
|
||||
designs = rec.of("designs")
|
||||
made = [e[0] for call in designs if call.get("replace") for e in call["entries"]]
|
||||
patched = {
|
||||
round(e[0], 2): e[1]
|
||||
for call in designs
|
||||
if call.get("replace") is False
|
||||
for e in call["entries"]
|
||||
}
|
||||
for patch in self.cross_patches:
|
||||
ch = patch["chainage_m"]
|
||||
body = {k: v for k, v in patch.items() if k != "chainage_m"}
|
||||
got = patched.get(round(ch, 2))
|
||||
if got is None or any(got.get(k) != v for k, v in body.items()):
|
||||
self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}")
|
||||
elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]):
|
||||
self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림")
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, Any]:
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
return {
|
||||
"pipe_points.json": _read(root, PIPES_FILE)["points"],
|
||||
"structures.json": _read(root, STRUCTURES_FILE)["structures"],
|
||||
"계획고 수정": lon["profile_alignment"].get("edits"),
|
||||
"종단 측점": [
|
||||
(s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resave_unchanged(flow: Flow) -> None:
|
||||
"""다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함."""
|
||||
flow.put_pipes(flow.server_pipes())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.flush_culvert()
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"])
|
||||
def test_저장_왕복_0_차이(api, save):
|
||||
flow = Flow(api)
|
||||
save(flow)
|
||||
flow.check()
|
||||
before = _snapshot(api.root)
|
||||
_resave_unchanged(flow)
|
||||
after = _snapshot(api.root)
|
||||
flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]]
|
||||
assert not flow.diffs, "\n".join(flow.diffs)
|
||||
Reference in New Issue
Block a user