diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index ebf385d78..d309e903f 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -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, diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 6111af307..2b008b81d 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. diff --git a/docs/wiki/concepts/design_page/operations_b05.md b/docs/wiki/concepts/design_page/operations_b05.md index 352272882..af516d1c1 100644 --- a/docs/wiki/concepts/design_page/operations_b05.md +++ b/docs/wiki/concepts/design_page/operations_b05.md @@ -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` | ## 시설(관 · 구조물 · 추가 측점) diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 271b5546c..4854979b6 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -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` | — | diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 4ed12bff6..d5b5fc733 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -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} diff --git a/resources/tester/test_476_1_apply_initial.py b/resources/tester/test_476_1_apply_initial.py new file mode 100644 index 000000000..16c814f9d --- /dev/null +++ b/resources/tester/test_476_1_apply_initial.py @@ -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