fix(476-1 ①): 추천 [적용] = 늘 새 규칙 초기 종단 — 옛 계획고 · 편집 · L 안 옮김

- run_route_save_chain `initial`(= as_initial) — 끔 · 균형 셈 실패면 `_carry_profile` 안 부름 = 막 쓴 1차 계획선(438 · L 20) 그대로
- 손 노선 [저장]은 그대로 계획고 옮김
- 횡단 손 입력 넘김 판정 한 자리 `carries_cross_inputs`(사용자 답 대기 · 지금 = 넘김)
- 시험 test_476_1_apply_initial · 346-3 가짜 _recompute 칸 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 16:31:16 +09:00
co-authored by Claude Opus 5.5
parent b14de92621
commit 858ef3b40f
6 changed files with 124 additions and 5 deletions
+10 -3
View File
@@ -22,7 +22,7 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만.
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) 또는 1차 계획선(438).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
"""
@@ -258,6 +258,11 @@ async def _carry_profile(
await connection.commit()
def carries_cross_inputs(initial: bool) -> bool:
"""횡단 손 입력을 새 측점(같은 자리)에 넘기나 — 추천 [적용]도 넘김(475-3 · 사용자 답 대기 476)."""
return True
async def _carry_cross(
project_id: UUID,
project_root: Path,
@@ -358,11 +363,13 @@ async def run_route_save_chain(
previous_params: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
planned_lines: tuple[list[list[float]], list[list[float]]] | None = None,
initial: bool = False,
) -> dict[str, Any]:
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤).
`planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름
(없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림).
`initial` = 추천 [적용](`as_initial`) — 옛 계획고 · 편집 · L 안 옮김 · 새 규칙 초기 종단(476).
"""
import aiomysql
@@ -465,7 +472,7 @@ async def run_route_save_chain(
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
else:
elif not initial: # [적용] = 막 쓴 1차 계획선(438 · L 20) 그대로(476 · 요구 276)
await _carry_profile(
project_root,
relative,
@@ -482,7 +489,7 @@ async def run_route_save_chain(
new_route_id,
relative,
old_long,
old_designs,
old_designs if carries_cross_inputs(initial) else [],
# 옛 관 = 옛 노선 누가거리(B05 가 읽는 자리 · 정본은 다른 선 누가거리일 수 있음 · 459)
[pipe for _, pipe in follow_pipes(old_pipes, vertices_of(old_xy))],
old_structures,
+3
View File
@@ -333,6 +333,7 @@ async def _recompute(
balanced: RouteInitialRequest | None = None,
terrain: Literal["las", "sheet"] | None = None,
planned_lines: tuple[list[list[float]], list[list[float]]] | None = None,
initial: bool = False,
) -> dict[str, Any]:
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.
@@ -441,6 +442,7 @@ async def _recompute(
previous_params,
target,
planned_lines,
initial,
)
failure = saved.get("error")
marks.append(("재확정 체인", time.perf_counter()))
@@ -621,6 +623,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
),
request.terrain,
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
as_initial,
)
if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
@@ -21,7 +21,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 교점 추가 · 삭제 | 편집기 메모리 → `route-draft` | 묶음 전 단계 · 측점 수 바뀜 → 갈아끼움 | 같음 | 같음 | 11 | `B05_Profile_UI_RouteEdit_Input.ts:149-158` · `logic/B05_Profile_UI_RouteEdit_Edits.ts:410-426` |
| 곡선 R · L | 편집기 `curveRadius` · `curveArc` → `route-draft` | 묶음 전 단계 · 확폭 · MC 이동 → 곡선 열쇠 옮김(`curveMoves`) | 같음 | `/design/save` `route` 칸 — 넣은 R · L 로 서버가 같은 규칙 | 11 | `B05_Profile_UI_RouteEdit_CurveBar.ts:76-162` · `logic/B05_Profile_Sections_Stations.ts:332-337` · `B05_Profile_Router_Replan.py:582 · 599-600` |
| 스냅 켬 · 간격 | 간격 = pref `routeedit-snap-step` · 켬 = `route-draft.stationSnap` | 켬 = 곡선 IP 를 옮김(한 걸음) → 묶음 전 단계 | — | 곡선표 파일 · 서버는 측점만 맞춤(IP 맞춤은 브라우저만) | 3 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` · `logic/design_pipeline_stations.ts:52-59` · `B05_Profile_Engine_Sections_Core.py:457-464` |
| 추천 노선 [적용] | result `routeedit-initial` → `route-draft` | 묶음 한 바퀴 | 미리 셈 `/route/initial` → `route_recommend/*` | **저장 밖** — 바로 `/route/replan`(as_initial) · 초기값 다시 뜸 · 초안 전부 지움 · 페이지 다시 세움 | 11 | `B05_Profile_UI_RouteEdit_Initial.ts:139 · 161-181` · `B05_Profile_UI_RouteEdit_Apply.ts:211 · 220-245` · `B05_Profile_Router_Replan.py:659-662` |
| 추천 노선 [적용] | result `routeedit-initial` → `route-draft` | 묶음 한 바퀴 | 미리 셈 `/route/initial` → `route_recommend/*` | **저장 밖** — 바로 `/route/replan`(as_initial) · 종단 = 새 규칙 초기 종단(옛 계획고 · 편집 · L 안 옮김 · 476) · 초기값 다시 뜸 · 초안 전부 지움 · 페이지 다시 세움 | 11 | `B05_Profile_UI_RouteEdit_Initial.ts:139 · 161-181` · `B05_Profile_UI_RouteEdit_Apply.ts:211 · 220-245` · `B05_Profile_Router_Replan.py:659-662` |
## 시설(관 · 구조물 · 추가 측점)
+1
View File
@@ -30,6 +30,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` |
| 노선 바뀜 넘김 = 열쇠마다 지반 · 측구 종류 · 측구 선택 · 사면 쉬프트 · 소단 · 소단 깊이 · 자리 칸(`SEAT_KEYS` = 암 경계 · 암 절토 경사 · 2단 경사 · 손 상단측 = 단면 유형 · 측구 방향)은 같은 자리(`sameSeat` · 중심 0.01 m · 축 1e-3) 측점만 · 새 자리 = 기본값 · 세션 값은 쓴 때 자리(`@seat`)로 판정(↶ ↷ 유지) · 끌기 갈아끼움 · 미리보기 · Node 넘김 · 서버 노선 저장 한 규칙 | `logic/B06_Section_Station_Swap.ts` SEAT_KEYS · unseated · `logic/B06_Section_Route_Carry.ts` carryKeyedStates · `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` crossAtSeat · `B05_Profile_Route_Save_Parts.py` carry_cross_inputs | `test_475_3_seat_carry` |
| 노선 초안 ↶(버림) = 차례 하나 · 버림 동안 종단도 그리기 멈춤 · 낡음 다시 셈 미룸 → 되돌림 · 저장 기준 맞춤 · 뒤 차례 끝에 한 번 그림(사이 섞인 유토곡선 없음) | `design_pipeline_page.ts:642 · 403` · `B06_Section_Section_Store.ts:316` | `test_472_5c_drop_once` |
| 추천 [적용] 종단 = 새 규칙 초기 종단 — 끔 · 균형 셈 실패 = 1차 계획선(438 · L 20) · 켬 = 균형 종단을 꼭짓점에 얹음(438) · 옛 계획고 · 편집 · L 안 옮김 · 손 노선 저장만 옮김 | `B05_Profile_Route_Save.py` run_route_save_chain `initial` | `test_476_1_apply_initial` |
| B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — |
| 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` |
| 정본 자리 = 값을 낳은 주인 — 노선 시설 = `pipe_points.json` · `structures.json` · 횡단 값 = 횡단 패치 | `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B05_Profile_Structures_Router.py:386-405` · `B06_Section_Section_Store.ts:449` | — |
+3 -1
View File
@@ -487,7 +487,9 @@ def test_추천_적용은_초기값_다시(api_world):
def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_world, monkeypatch):
calls: list = []
async def recompute(project_id, root, stored, balanced=None, terrain=None, lines=None):
async def recompute(
project_id, root, stored, balanced=None, terrain=None, lines=None, initial=False
): # 476 — [적용] = initial 칸 더함
calls.append((balanced, lines))
return {"route_id": 9, "total_length_m": 10.0}
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# ruff: noqa: F811 — 346-3 fixture 를 불러 씀
"""476-1 ① 추천 [적용] = 늘 새 규칙 초기 종단(요구 276) — 옛 계획고 · 편집 · L 안 옮김.
체인은 346-3 시험의 가짜 세계(`chain_world`)로 · 종단 파일은 실제 1차 계획선.
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from uuid import uuid4
ROOT = Path(__file__).resolve().parents[2]
TESTER = Path(__file__).resolve().parent
for path in (ROOT, TESTER):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from test_346_3_route_save import ( # noqa: E402, F401 — 가짜 세계 fixture
NEW,
_async,
_longitudinal,
_run,
api_world,
chain_world,
)
import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402
import B06_Section.B06_Section_Router as b06_router # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of # noqa: E402
LONG = "B06_Section/longitudinal/longitudinal.json"
def _real_profile_world(world, monkeypatch):
"""옛 종단 = 손 편집 · L 15 · 새 종횡단 = 실제 1차 계획선 — `_carry_profile` 은 진짜."""
total = float(vertices_of(NEW)[-1].chainage_m)
old = _longitudinal(total)
old["profile_alignment"]["edits"] = {"station_offsets": {"40.000": 0.7}, "curve_radii": {}}
old["profile_alignment"]["policy"]["default_curve_length_m"] = 15.0
path = world.root / LONG
path.write_text(json.dumps(old), "utf-8")
fresh = _longitudinal(total)
async def regenerate(*args, **kwargs):
world.order.append("sections")
path.write_text(json.dumps(fresh), "utf-8")
monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate)
monkeypatch.setattr(save, "_carry_profile", _CARRY)
monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None))
return path, fresh["profile_alignment"]
_CARRY = save._carry_profile
def test_적용_끔이면_계획고_안_옮김(chain_world):
result = _run(chain_world, initial=True)
assert "error" not in result, result
assert "profile" not in chain_world.order # 옛 코드 = 같은 노선 옛 꼭짓점 통째 옮김
def test_적용_같은_노선_끔_새_1차_계획선(chain_world, monkeypatch):
path, fresh = _real_profile_world(chain_world, monkeypatch)
_run(chain_world, initial=True)
alignment = json.loads(path.read_text("utf-8"))["profile_alignment"]
assert alignment == fresh # 꼭짓점 · L(정책) · 편집 = 새 1차 계획선(438) 그대로
assert not (alignment.get("edits") or {}).get("station_offsets")
def test_손_노선_저장은_계획고_옮김_그대로(chain_world, monkeypatch):
path, fresh = _real_profile_world(chain_world, monkeypatch)
_run(chain_world)
alignment = json.loads(path.read_text("utf-8"))["profile_alignment"]
assert alignment["edits"]["station_offsets"].get("40.000") == 0.7 # 앞 구간 손 편집 옮김
def test_적용_켬_균형_셈_실패도_1차_계획선(chain_world, monkeypatch):
path, fresh = _real_profile_world(chain_world, monkeypatch)
_run(chain_world, profile_target=None, initial=True) # 균형 목표 None(셈 실패)
assert json.loads(path.read_text("utf-8"))["profile_alignment"] == fresh
def test_적용은_체인에_initial(api_world, monkeypatch):
seen: list = []
async def recompute(*args, **kwargs):
seen.append(args[6] if len(args) > 6 else kwargs.get("initial"))
return {"route_id": 9, "total_length_m": 10.0}
monkeypatch.setattr(replan, "_recompute", recompute)
body = save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}])
asyncio.run(replan.replan_route(uuid4(), body))
asyncio.run(replan.replan_route(uuid4(), body.model_copy(update={"as_initial": True})))
assert seen == [False, True]
def test_횡단_손_입력_넘김_한_자리():
# 사용자 답 전 = [적용]도 넘김(475-3) — 답 뒤 이 함수 한 줄
assert save.carries_cross_inputs(True) is True and save.carries_cross_inputs(False) is True