perf(342-2): 노선 [확인] 체인 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 길 모형 서버 짓기
- 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움) - 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례) - 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만 - 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -18,6 +18,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -65,7 +66,11 @@ def _planned_route_points_in_project_crs(
|
||||
|
||||
|
||||
async def _prepare_drainage_pipes_and_reprofile(
|
||||
project_id: UUID, route_id: int, *, refresh: bool = False
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
*,
|
||||
refresh: bool = False,
|
||||
stored_options: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
|
||||
|
||||
@@ -75,12 +80,13 @@ async def _prepare_drainage_pipes_and_reprofile(
|
||||
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
|
||||
|
||||
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
|
||||
|
||||
`stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션.
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
|
||||
from B06_Section.B06_Section_Repository import get_latest_section_options
|
||||
from B06_Section.B06_Section_Router import regenerate_sections
|
||||
from B06_Section.B06_Section_Schema import SectionRegenerateRequest
|
||||
from B06_Section.B06_Section_Router import regenerate_route_sections
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
|
||||
|
||||
@@ -113,14 +119,16 @@ async def _prepare_drainage_pipes_and_reprofile(
|
||||
marks.append(("관 지점 확정", time.perf_counter()))
|
||||
|
||||
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
seeded = stored_options is not None
|
||||
if not seeded:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
half_width = float(
|
||||
(stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M
|
||||
)
|
||||
regenerated = await regenerate_sections(
|
||||
project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width)
|
||||
regenerated = await regenerate_route_sections(
|
||||
project_id, route_id, half_width, stored_options=stored_options if seeded else None
|
||||
)
|
||||
if isinstance(regenerated, JSONResponse):
|
||||
logger.error(
|
||||
@@ -319,26 +327,8 @@ async def run_auto_design_chain(
|
||||
route_id,
|
||||
)
|
||||
|
||||
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
|
||||
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
|
||||
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
|
||||
try:
|
||||
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
|
||||
|
||||
await prebuild_corridor(project_id, route_id, project_root)
|
||||
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
||||
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
|
||||
|
||||
# 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에
|
||||
# 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는
|
||||
# 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴
|
||||
# 기준선에도 보정이 담긴다.
|
||||
try:
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
|
||||
|
||||
await recompute_server_side(project_id, route_id)
|
||||
except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다
|
||||
logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id)
|
||||
# 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서
|
||||
# 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2).
|
||||
|
||||
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
||||
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
||||
@@ -426,13 +416,19 @@ async def run_redesign_chain(
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
|
||||
from B05_Profile.B05_Profile_Router import (
|
||||
_section_options,
|
||||
confirm_latest_route,
|
||||
solve_route_record,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router import regenerate_route_sections
|
||||
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
|
||||
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
@@ -476,6 +472,7 @@ async def run_redesign_chain(
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id)
|
||||
old_designs = await get_cross_section_designs(connection, old_route_id)
|
||||
old_section_options = await get_latest_section_options(connection, project_id)
|
||||
stage2 = next(
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
@@ -528,7 +525,8 @@ async def run_redesign_chain(
|
||||
start_elevation_offset_m=params.get("start_elevation_offset_m"),
|
||||
end_elevation_offset_m=params.get("end_elevation_offset_m"),
|
||||
)
|
||||
solve_result: Any = await solve_route(project_id, request)
|
||||
# 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일.
|
||||
solve_result: Any = await solve_route_record(project_id, request, generate_sections=False)
|
||||
if isinstance(solve_result, JSONResponse):
|
||||
# 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06
|
||||
# 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음).
|
||||
@@ -571,7 +569,17 @@ async def run_redesign_chain(
|
||||
marks.append(("B05 확정", time.perf_counter()))
|
||||
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
|
||||
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
|
||||
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
|
||||
# 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다.
|
||||
seed = asdict(_section_options(request, old_section_options))
|
||||
seed.pop("extra_stations", None)
|
||||
piped = await _prepare_drainage_pipes_and_reprofile(
|
||||
project_id, new_route_id, refresh=True, stored_options=seed
|
||||
)
|
||||
if not piped:
|
||||
# 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀).
|
||||
await regenerate_route_sections(
|
||||
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
|
||||
)
|
||||
|
||||
marks.append(("배수·관·측점 재생성", time.perf_counter()))
|
||||
|
||||
|
||||
@@ -131,6 +131,17 @@ async def solve_route(
|
||||
project_id: UUID, request: RouteSolveRequest
|
||||
) -> RouteSolveResponse | JSONResponse:
|
||||
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
||||
return await solve_route_record(project_id, request)
|
||||
|
||||
|
||||
async def solve_route_record(
|
||||
project_id: UUID, request: RouteSolveRequest, *, generate_sections: bool = True
|
||||
) -> RouteSolveResponse | JSONResponse:
|
||||
"""`solve_route` 본체 — `generate_sections=False` 면 노선 행만 남기고 종횡단은 안 만든다.
|
||||
|
||||
노선 [확인] 체인은 관을 정한 뒤 종횡단을 한 번만 만든다(342-2) — 여기서 만들면 관 없는
|
||||
종횡단을 지었다가 곧바로 통째로 덮는 헛일(1.5~2 s)이 된다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
params = {
|
||||
@@ -269,55 +280,56 @@ async def solve_route(
|
||||
longitudinal_length_m: float | None = None
|
||||
cross_section_count: int | None = None
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
try:
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
stored_grade_options = await get_latest_grade_options(connection, project_id)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
design["route_data_path"],
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request, stored_options),
|
||||
grade_options=_grade_options(request, stored_grade_options),
|
||||
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
|
||||
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
|
||||
grade_overrides=request.grade_options(),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
await connection.begin()
|
||||
if generate_sections:
|
||||
try:
|
||||
await delete_sections_for_route(connection, route_id)
|
||||
await create_longitudinal_section(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=route_id,
|
||||
data=sections["longitudinal"]["data"],
|
||||
longitudinal_file_path=sections["longitudinal"]["file_path"],
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
await insert_cross_sections(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=route_id,
|
||||
sections=sections["cross_sections"],
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
stored_grade_options = await get_latest_grade_options(connection, project_id)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
design["route_data_path"],
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request, stored_options),
|
||||
grade_options=_grade_options(request, stored_grade_options),
|
||||
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
|
||||
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
|
||||
grade_overrides=request.grade_options(),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
await connection.commit()
|
||||
await connection.begin()
|
||||
try:
|
||||
await delete_sections_for_route(connection, route_id)
|
||||
await create_longitudinal_section(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=route_id,
|
||||
data=sections["longitudinal"]["data"],
|
||||
longitudinal_file_path=sections["longitudinal"]["file_path"],
|
||||
)
|
||||
await insert_cross_sections(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=route_id,
|
||||
sections=sections["cross_sections"],
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
||||
cross_section_count = len(sections["cross_sections"])
|
||||
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
||||
cross_section_count = len(sections["cross_sections"])
|
||||
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
||||
project_id,
|
||||
route_id,
|
||||
)
|
||||
logger.exception(
|
||||
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
||||
project_id,
|
||||
route_id,
|
||||
)
|
||||
|
||||
return RouteSolveResponse(
|
||||
project_id=str(project_id),
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
"""B05 3D 예상형상(코리도) 서피스 영구저장 라우터.
|
||||
|
||||
코리도 메쉬는 브라우저(TS 단일 구현)가 종횡단 정본으로 빌드한 **파생 데이터**다.
|
||||
여기서는 파일 보관·조회만 한다 — 기하 계산 없음(2026-08-23 계획).
|
||||
저장 흐름: 초기 빌드 직후 1회 + 임시저장·종단/횡단 페이지 이동 시 갱신.
|
||||
코리도 메쉬는 종횡단 정본에서 나온 **파생 데이터**다. 짓기는 서버 Node 가 같은 TS 로 한다
|
||||
(`B05_Profile_Corridor_Prebuild` — 자동 체인 · 노선 [확인] 체인 · B06 [저장] · [확정] 끝 · 342-2).
|
||||
여기서는 조회만 한다 — 브라우저 올림(PUT)은 없앰(서버 검산 없이 받아 적던 규칙 어김).
|
||||
검증(버전 해시)은 프론트가 종횡단 정본과 대조한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -25,9 +23,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"])
|
||||
|
||||
# 코리도 JSON 상한 — 리본 좌표(base64 Float32) 기준 넉넉히. 초과는 비정상 요청.
|
||||
MAX_CORRIDOR_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
"""코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다."""
|
||||
@@ -126,36 +121,3 @@ async def get_corridor(
|
||||
return JSONResponse(
|
||||
status_code=500, content={"status": "error", "message": "코리도 조회에 실패했습니다."}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/routes/{route_id}/corridor", response_model=None)
|
||||
async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...)) -> JSONResponse:
|
||||
"""코리도 파일 저장(원자적 덮어쓰기). 내용 검증은 최소 — 파생 데이터 보관용."""
|
||||
if not isinstance(payload, dict) or "ribbons" not in payload:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"status": "error", "message": "코리도 형식이 올바르지 않습니다."},
|
||||
)
|
||||
try:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(encoded.encode("utf-8")) > MAX_CORRIDOR_BYTES:
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={"status": "error", "message": "코리도 데이터가 허용 크기를 넘습니다."},
|
||||
)
|
||||
project_root = await _resolve_project_root(project_id)
|
||||
if project_root is None:
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = corridor_path(project_root, route_id)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(atomic_write_json, path, payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"B05 코리도 저장 실패: project_id=%s route_id=%s error=%s", project_id, route_id, exc
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500, content={"status": "error", "message": "코리도 저장에 실패했습니다."}
|
||||
)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor.ts
|
||||
* 코리도(3D 예상형상) 오케스트레이션 — 로드·빌드·저장·캐시.
|
||||
* 코리도(3D 예상형상) 오케스트레이션 — 받기·미리보기 짓기·캐시.
|
||||
*
|
||||
* 흐름(2026-08-23 사용자 확정):
|
||||
* 1) 초기: 종횡단 정본 로드 시 브라우저가 정밀 빌드 → 표시. 저장본이 없던
|
||||
* 경우에만 즉시 영구저장(PUT).
|
||||
* 2) 재접근: 저장본 GET → 버전 해시가 현 정본과 일치하면 그대로 표시,
|
||||
* 불일치·404면 재빌드(dirty 표시).
|
||||
* 3) 수정: 재빌드는 메모리에서만 — 임시저장·종단/횡단 페이지 이동 시
|
||||
* saveCorridorIfDirty()로 영구저장.
|
||||
* 흐름(342-2 · 사용자 확정 10-16):
|
||||
* 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 ·
|
||||
* B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음).
|
||||
* 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시.
|
||||
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로
|
||||
* 지음 — 메모리에만 · 올리지 않음.
|
||||
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
|
||||
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
|
||||
* ========================================================================== */
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
corridorHash,
|
||||
ENVELOPE_VERSION,
|
||||
deserialize,
|
||||
serialize,
|
||||
type CorridorEnvelope,
|
||||
} from "./B05_Profile_UI_Corridor_Envelope";
|
||||
|
||||
@@ -40,7 +38,6 @@ export interface CorridorStructureHook {
|
||||
interface CacheEntry {
|
||||
hash: string;
|
||||
build: CorridorBuildResult;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
@@ -116,26 +113,9 @@ async function fetchStored(
|
||||
}
|
||||
}
|
||||
|
||||
async function putStored(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
envelope: CorridorEnvelope,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환.
|
||||
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는
|
||||
* saveCorridorIfDirty 시점의 최신 상태를 담는다).
|
||||
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다.
|
||||
*/
|
||||
/** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */
|
||||
export type ProfileSamples = Array<{
|
||||
@@ -176,7 +156,7 @@ export async function ensureCorridor(
|
||||
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
|
||||
try {
|
||||
const build = deserialize(lookup.envelope);
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
cache.set(key, { hash, build });
|
||||
markSource("stored", hash);
|
||||
return build;
|
||||
} catch {
|
||||
@@ -186,10 +166,6 @@ export async function ensureCorridor(
|
||||
// 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아
|
||||
// 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫).
|
||||
if (!rebuild) return null;
|
||||
// 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이
|
||||
// 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다.
|
||||
const hasStored = lookup.kind !== "missing";
|
||||
|
||||
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
|
||||
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
|
||||
// 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③).
|
||||
@@ -202,40 +178,14 @@ export async function ensureCorridor(
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
// 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음)
|
||||
markSource("built", hash);
|
||||
if (!hasStored) {
|
||||
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
void putStored(projectId, routeId, serialize(build, hash)).then((ok) => {
|
||||
if (!ok) markDirty(projectId, routeId);
|
||||
});
|
||||
} else {
|
||||
// 정본이 저장본보다 새것 — 임시저장·페이지 이동 때 저장한다(사용자 확정 흐름).
|
||||
cache.set(key, { hash, build, dirty: true });
|
||||
}
|
||||
cache.set(key, { hash, build });
|
||||
return build;
|
||||
}
|
||||
|
||||
function markDirty(projectId: string, routeId: number): void {
|
||||
const entry = cache.get(keyOf(projectId, routeId));
|
||||
if (entry) entry.dirty = true;
|
||||
}
|
||||
|
||||
/** 임시저장·B05↔B06 페이지 이동 훅 — 미저장 변경분이 있을 때만 PUT. */
|
||||
export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise<void> {
|
||||
const entry = cache.get(keyOf(projectId, routeId));
|
||||
if (!entry || !entry.dirty) return;
|
||||
const envelope = serialize(entry.build, entry.hash);
|
||||
const ok = await putStored(projectId, routeId, envelope);
|
||||
if (!ok) return;
|
||||
entry.dirty = false;
|
||||
// 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가
|
||||
// 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다.
|
||||
const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`;
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer;
|
||||
await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId));
|
||||
await writeCachedBytes(projectId, url, bytes);
|
||||
}
|
||||
/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */
|
||||
export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise<void> {}
|
||||
|
||||
/** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
|
||||
function withSections(
|
||||
|
||||
@@ -379,6 +379,21 @@ async def regenerate_sections(
|
||||
project_id: UUID, route_id: int, request: SectionRegenerateRequest
|
||||
) -> SectionDetailResponse | JSONResponse:
|
||||
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다."""
|
||||
return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m)
|
||||
|
||||
|
||||
async def regenerate_route_sections(
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
cross_half_width_m: float,
|
||||
*,
|
||||
stored_options: dict[str, Any] | None = None,
|
||||
) -> SectionDetailResponse | JSONResponse:
|
||||
"""`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다.
|
||||
|
||||
노선 [확인] 체인은 새 노선의 첫 생성을 건너뛰므로(342-2) DB 최신 종단이 아직 옛 노선 것이다 —
|
||||
건너뛴 첫 생성이 남겼을 옵션을 체인이 넘겨 결과를 지금과 같게 둔다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
@@ -390,7 +405,8 @@ async def regenerate_sections(
|
||||
)
|
||||
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
if stored_options is None:
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
workflow = await get_workflow_state(cursor, str(project_id))
|
||||
route_stage = next(
|
||||
@@ -411,7 +427,7 @@ async def regenerate_sections(
|
||||
options=_regeneration_options(
|
||||
stored_options,
|
||||
stage_params,
|
||||
request.cross_half_width_m,
|
||||
cross_half_width_m,
|
||||
),
|
||||
# 계획선까지 함께 재계산·저장 — 없으면 계획 횡단도선·유토곡선이 사라진다.
|
||||
grade_options=_regeneration_grade_options(stage_params),
|
||||
|
||||
@@ -186,6 +186,19 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None:
|
||||
"""3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝.
|
||||
|
||||
브라우저는 열쇠(해시)가 맞으면 받기만 한다. 실패는 비치명적 — 저장은 그대로 남는다.
|
||||
"""
|
||||
try:
|
||||
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
|
||||
|
||||
await prebuild_corridor(project_id, route_id, project_root)
|
||||
except Exception:
|
||||
logger.exception("3D 길 모형 서버 짓기 실패 (저장은 유지): route_id=%s", route_id)
|
||||
|
||||
|
||||
async def _stale_route_response(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> JSONResponse | None:
|
||||
@@ -276,6 +289,7 @@ async def save_sections(
|
||||
raise
|
||||
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다.
|
||||
await _recompute_stored_designs(project_id, route_id)
|
||||
await _prebuild_corridor(project_id, route_id, project_root)
|
||||
return SectionConfirmResponse(
|
||||
project_id=str(project_id), route_id=route_id, confirmed=False
|
||||
)
|
||||
@@ -401,6 +415,9 @@ async def confirm_sections(
|
||||
route_id,
|
||||
)
|
||||
marks.append(("측구 방향 B05 역반영", time.perf_counter()))
|
||||
# 역반영까지 끝난 정본으로 3D 길 모형(자동 · 노선 [확인] 체인도 이 끝을 지남)
|
||||
await _prebuild_corridor(project_id, route_id, project_root)
|
||||
marks.append(("3D 길 모형 서버 짓기", time.perf_counter()))
|
||||
_log_steps("B06 확정", marks)
|
||||
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""342-2 노선 [확인] 체인 — 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝.
|
||||
|
||||
① 노선 [확인] 체인(`run_redesign_chain`)이 종횡단을 **한 번만** 만들고, 그 한 번이 옛 흐름의
|
||||
마지막 생성(4c `regenerate_sections`)과 **같은 입력**(옵션 · 반폭 · 계획선 기준)을 받는다.
|
||||
옛 흐름은 solve 안 첫 생성이 남긴 옵션을 4c 가 읽었다 — 그 옵션을 체인이 넘겨 같게 둠.
|
||||
② 배수유역이 실패해도 종횡단은 선다(첫 생성을 건너뛰었으므로 체인이 한 번 세움).
|
||||
③ 자동 체인은 서버 재계산 · 3D 짓기를 따로 부르지 않음 — [확정](confirm_sections) 끝 한 번씩 ·
|
||||
재계산 → 짓기 차례 · [저장](save_sections)도 끝에 짓기.
|
||||
|
||||
DB · 엔진은 가짜로 바꿔 체인의 차례와 생성 입력만 잰다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B03_FileInput.B03_FileInput_Repository as b03_repo # noqa: E402
|
||||
import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402
|
||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins # noqa: E402
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402
|
||||
import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402
|
||||
import B05_Profile.B05_Profile_Router as b05_router # noqa: E402
|
||||
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
|
||||
import B06_Section.B06_Section_Repository_Bulk as b06_bulk # noqa: E402
|
||||
import B06_Section.B06_Section_Router as b06_router # noqa: E402
|
||||
import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402
|
||||
import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402
|
||||
import common_util.common_util_initial_snapshot as snapshot # noqa: E402
|
||||
import common_util.common_util_storage as storage # noqa: E402
|
||||
import common_util.common_util_workflow_state as workflow # noqa: E402
|
||||
import config.config_db as config_db # noqa: E402
|
||||
from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
|
||||
# stage 2 params(요청 쪽) · 옛 노선 종단 옵션(DB 쪽)이 **엇갈리게** — 옛 흐름 순서를 못 지키면 값이 갈린다
|
||||
PARAMS = {
|
||||
"filter_key": "csf",
|
||||
"method": "dtm",
|
||||
"smooth": False,
|
||||
"points": {"bp": {"x": 0.0, "y": 0.0}, "ep": {"x": 100.0, "y": 0.0}, "cp": []},
|
||||
"options": {"grade_class": "trunk", "terrain_type": "normal"},
|
||||
"cross_half_width_m": 20.0,
|
||||
"cross_sample_interval_m": 0.5,
|
||||
"station_interval_m": None,
|
||||
"long_sample_interval_m": None,
|
||||
}
|
||||
OLD_STORED = {
|
||||
"station_interval_m": 25.0,
|
||||
"cross_half_width_m": 30.0,
|
||||
"cross_sample_interval_m": 1.0,
|
||||
"long_sample_interval_m": 2.0,
|
||||
}
|
||||
|
||||
|
||||
class _Cursor:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def execute(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Connection:
|
||||
def cursor(self, *args):
|
||||
return _Cursor()
|
||||
|
||||
async def begin(self):
|
||||
return None
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return _Connection()
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
return _Acquire()
|
||||
|
||||
|
||||
def _async(value):
|
||||
async def fake(*args, **kwargs):
|
||||
return value
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(monkeypatch, tmp_path):
|
||||
calls: dict = {"generate": [], "order": [], "latest_options": 0}
|
||||
|
||||
async def solve(project_id, request, *, generate_sections=True):
|
||||
calls["order"].append("solve")
|
||||
calls["solve_generates"] = generate_sections
|
||||
return SimpleNamespace(route_id=6, total_length_m=100.0)
|
||||
|
||||
async def primary_region(project_id, refresh=False):
|
||||
calls["order"].append("drainage")
|
||||
return calls.get("region", {})
|
||||
|
||||
async def latest_options(connection, project_id):
|
||||
calls["latest_options"] += 1
|
||||
return OLD_STORED
|
||||
|
||||
def generate(project_root, route_data_path, filter_key, method, smooth, **kwargs):
|
||||
calls["order"].append("generate")
|
||||
calls["generate"].append(kwargs)
|
||||
data = {"length_m": 100.0, "options": {}}
|
||||
return {
|
||||
"longitudinal": {"data": data, "file_path": "long.json"},
|
||||
"cross_sections": [],
|
||||
"result": {"longitudinal": {}, "cross_sections": []},
|
||||
}
|
||||
|
||||
async def confirm(project_id, route_id, request=None, mark_stage_complete=True):
|
||||
calls["order"].append("confirm_sections")
|
||||
return SimpleNamespace(route_id=route_id)
|
||||
|
||||
state = {"stages": [{"stage_no": 2, "params": PARAMS}]}
|
||||
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 5}))
|
||||
monkeypatch.setattr(b03_repo, "get_project_storage_relative_path", _async("p"))
|
||||
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda path: str(tmp_path))
|
||||
monkeypatch.setattr(snapshot, "mark_designing", lambda root: None)
|
||||
monkeypatch.setattr(snapshot, "clear_designing", lambda root: None)
|
||||
monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda root: False)
|
||||
monkeypatch.setattr(workflow, "get_workflow_state", _async(state))
|
||||
monkeypatch.setattr(
|
||||
b06_repo, "get_longitudinal_section", _async({"data": {}, "longitudinal_file_path": "x"})
|
||||
)
|
||||
monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([]))
|
||||
monkeypatch.setattr(b06_repo, "get_latest_section_options", latest_options)
|
||||
monkeypatch.setattr(b05_router, "solve_route_record", solve)
|
||||
monkeypatch.setattr(b05_router, "confirm_latest_route", _async({}))
|
||||
monkeypatch.setattr(watershed, "get_primary_region", primary_region)
|
||||
monkeypatch.setattr(basins, "put_pipe_points", _async({"saved_count": 2}))
|
||||
monkeypatch.setattr(b06_confirm, "_rowless_station_chainages", lambda *a: [])
|
||||
monkeypatch.setattr(b06_confirm, "confirm_sections", confirm)
|
||||
monkeypatch.setattr(b06_design, "compute_default_designs", lambda *a: [])
|
||||
monkeypatch.setattr(b06_bulk, "merge_cross_section_designs", _async(0))
|
||||
# 재생성 본체(B06_Section_Router) — DB 는 가짜 · 생성 입력만 잰다
|
||||
monkeypatch.setattr(b06_router, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(
|
||||
b06_router,
|
||||
"get_route_generation_source",
|
||||
_async({"route_data_path": "route.geojson", "crs_epsg": 5186}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
b06_router,
|
||||
"get_surface_confirmation_params",
|
||||
_async({"source_filter": "csf", "method": "dtm", "smooth": False}),
|
||||
)
|
||||
monkeypatch.setattr(b06_router, "get_project_storage_relative_path", _async("p"))
|
||||
monkeypatch.setattr(b06_router, "get_latest_section_options", latest_options)
|
||||
monkeypatch.setattr(b06_router, "get_workflow_state", _async(state))
|
||||
monkeypatch.setattr(b06_router, "resolve_stored_project_path", lambda path: str(tmp_path))
|
||||
monkeypatch.setattr(b06_router, "run_section_generation", generate)
|
||||
monkeypatch.setattr(b06_router, "delete_sections_for_route", _async(None))
|
||||
monkeypatch.setattr(b06_router, "create_longitudinal_section", _async(None))
|
||||
monkeypatch.setattr(b06_router, "insert_cross_sections", _async(None))
|
||||
monkeypatch.setattr(b06_router, "attach_saved_shapes", lambda *a: None)
|
||||
return calls
|
||||
|
||||
|
||||
def _old_flow_options():
|
||||
"""옛 흐름 — solve 첫 생성 옵션(요청 → 옛 저장 → 기본)을 4c 가 DB 에서 읽어 다시 고른 값."""
|
||||
request = RouteSolveRequest(
|
||||
filter_key="csf",
|
||||
method="dtm",
|
||||
smooth=False,
|
||||
algorithm="as_planned",
|
||||
bp=PARAMS["points"]["bp"],
|
||||
ep=PARAMS["points"]["ep"],
|
||||
cross_half_width_m=PARAMS["cross_half_width_m"],
|
||||
cross_sample_interval_m=PARAMS["cross_sample_interval_m"],
|
||||
)
|
||||
first = b05_router._section_options(request, OLD_STORED)
|
||||
stored_by_first = {**asdict(first), "extra_stations": []}
|
||||
return b06_router._regeneration_options(
|
||||
stored_by_first, PARAMS, float(stored_by_first["cross_half_width_m"])
|
||||
)
|
||||
|
||||
|
||||
def test_종횡단은_한_번_관_뒤에(world):
|
||||
failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
|
||||
assert failure is None
|
||||
assert world["solve_generates"] is False
|
||||
assert len(world["generate"]) == 1
|
||||
order = world["order"]
|
||||
assert order.index("solve") < order.index("drainage") < order.index("generate")
|
||||
assert order.index("generate") < order.index("confirm_sections")
|
||||
|
||||
|
||||
def test_한_번이_옛_마지막_생성과_같은_입력(world):
|
||||
asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
|
||||
got = world["generate"][0]
|
||||
expected = _old_flow_options()
|
||||
assert got["options"] == expected
|
||||
# 옛 노선 저장 반폭(30)이 아니라 요청 반폭(20) — 첫 생성이 남기던 값
|
||||
assert got["options"].cross_half_width_m == 20.0
|
||||
assert got["options"].cross_sample_interval_m == 0.5
|
||||
assert got["options"].station_interval_m == 25.0
|
||||
assert got["grade_options"] == b06_router._regeneration_grade_options(PARAMS)
|
||||
assert "grade_overrides" not in got # 옛 4c 도 안 넘김 — 저장본 grade_overrides 빈 값 그대로
|
||||
# 생성 쪽은 DB 최신 종단(=옛 노선)을 다시 읽지 않음 — 체인이 넘긴 옵션
|
||||
assert world["latest_options"] == 1 # 체인이 옛 노선 옵션을 읽은 한 번뿐
|
||||
|
||||
|
||||
def test_배수유역이_실패해도_종횡단은_선다(world):
|
||||
world["region"] = JSONResponse(status_code=500, content={})
|
||||
failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
|
||||
assert failure is None
|
||||
assert len(world["generate"]) == 1
|
||||
assert world["generate"][0]["options"] == _old_flow_options()
|
||||
|
||||
|
||||
def _body(source: str, name: str) -> str:
|
||||
start = source.index(f"async def {name}(")
|
||||
following = re.search(r"\n(async def|def|@router)", source[start + 10 :])
|
||||
return source[start : start + 10 + following.start()] if following else source[start:]
|
||||
|
||||
|
||||
def test_자동_체인은_재계산_짓기를_따로_안_부름():
|
||||
source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text("utf-8")
|
||||
auto = _body(source, "run_auto_design_chain")
|
||||
assert "recompute_server_side" not in auto
|
||||
assert "prebuild_corridor" not in auto
|
||||
assert "confirm_sections(" in auto
|
||||
|
||||
|
||||
def test_확정_저장_끝에서_재계산_뒤_짓기():
|
||||
source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8")
|
||||
for name in ("confirm_sections", "save_sections"):
|
||||
body = _body(source, name)
|
||||
assert body.count("_recompute_stored_designs(") == 1, name
|
||||
assert body.count("_prebuild_corridor(") == 1, name
|
||||
assert body.index("_recompute_stored_designs(") < body.index("_prebuild_corridor("), name
|
||||
confirm = _body(source, "confirm_sections")
|
||||
# 측구 방향 역반영(종단 파일 고침)까지 끝난 뒤 짓기
|
||||
assert confirm.index("_merge_uphill_overrides_into_longitudinal") < confirm.index(
|
||||
"_prebuild_corridor("
|
||||
)
|
||||
|
||||
|
||||
def test_브라우저는_3D_를_올리지_않음():
|
||||
source = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts").read_text("utf-8")
|
||||
assert '"PUT"' not in source
|
||||
assert "putStored" not in source
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B05 코리도(예상형상) 저장 라우터 검증.
|
||||
|
||||
파생 데이터 보관 계약 — 경로 규칙, PUT 검증(형식·크기), 파일 왕복.
|
||||
파생 데이터 보관 계약 — 경로 규칙 · 브라우저 올림(PUT) 없음(342-2 서버가 지음) · 파일 왕복.
|
||||
DB 의존(_resolve_project_root)은 스텁으로 대체해 파일 로직만 검증한다.
|
||||
"""
|
||||
|
||||
@@ -24,19 +24,16 @@ def test_corridor_path_layout(tmp_path):
|
||||
assert path == tmp_path / "B05_Profile" / "corridor" / "corridor_0007.json"
|
||||
|
||||
|
||||
def test_put_rejects_wrong_shape(tmp_path, monkeypatch):
|
||||
"""ribbons 없는 본문은 422 — 파생 데이터라도 최소 형식은 지킨다."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
response = asyncio.run(corridor.put_corridor(uuid4(), 1, {"nope": True}))
|
||||
assert response.status_code == 422
|
||||
def test_put_removed():
|
||||
"""브라우저 올림 길 없음 — 3D 길 모형은 서버 Node 가 [저장]·[확정]·체인 끝에 지음(342-2)."""
|
||||
assert not hasattr(corridor, "put_corridor")
|
||||
methods = {(route.path, method) for route in corridor.router.routes for method in route.methods}
|
||||
assert all(method != "PUT" for _, method in methods)
|
||||
|
||||
|
||||
def test_put_then_get_roundtrip(tmp_path, monkeypatch):
|
||||
"""PUT 저장본을 GET이 그대로 돌려준다(원자 기록·경로 일치)."""
|
||||
def test_stored_then_get_roundtrip(tmp_path, monkeypatch):
|
||||
"""서버가 둔 저장본을 GET이 그대로 돌려준다(경로 일치)."""
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
@@ -48,14 +45,15 @@ def test_put_then_get_roundtrip(tmp_path, monkeypatch):
|
||||
"ribbons": [{"kind": "cut", "side": "left", "colCount": 2, "chainages": [0, 2]}],
|
||||
"outline": {"chainages": [0, 2], "left": [[0, 1]], "right": [[0, -1]]},
|
||||
}
|
||||
put_response = asyncio.run(corridor.put_corridor(uuid4(), 3, payload))
|
||||
assert put_response.status_code == 200
|
||||
stored = tmp_path / "B05_Profile" / "corridor" / "corridor_0003.json"
|
||||
assert stored.is_file()
|
||||
stored = corridor.corridor_path(tmp_path, 3)
|
||||
stored.parent.mkdir(parents=True)
|
||||
atomic_write_json(stored, payload)
|
||||
|
||||
get_response = asyncio.run(corridor.get_corridor(uuid4(), 3))
|
||||
assert get_response.status_code == 200
|
||||
assert json.loads(get_response.body) == payload
|
||||
stale = asyncio.run(corridor.get_corridor(uuid4(), 3, hash="cafe"))
|
||||
assert json.loads(stale.body) == {"status": "stale", "hash": "deadbeef"}
|
||||
|
||||
|
||||
def test_get_missing_returns_404(tmp_path, monkeypatch):
|
||||
@@ -67,16 +65,3 @@ def test_get_missing_returns_404(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
response = asyncio.run(corridor.get_corridor(uuid4(), 99))
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_put_rejects_oversize(tmp_path, monkeypatch):
|
||||
"""크기 상한 초과는 413 — 비정상 요청 차단."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "MAX_CORRIDOR_BYTES", 64)
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
payload = {"version": 1, "hash": "x", "ribbons": [{"blob": "y" * 200}], "outline": {}}
|
||||
response = asyncio.run(corridor.put_corridor(uuid4(), 1, payload))
|
||||
assert response.status_code == 413
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -189,7 +190,9 @@ def test_이월은_측점_재생성_뒤에_한다():
|
||||
source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
rebuild = source.index("_prepare_drainage_pipes_and_reprofile(project_id, new_route_id")
|
||||
rebuild = re.search(
|
||||
r"_prepare_drainage_pipes_and_reprofile\(\s*project_id, new_route_id", source
|
||||
).start()
|
||||
carry = source.index("merge_cross_section_designs(")
|
||||
assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user