From b92fa06f34e14a4008ae33e25d4e2a8c09da3583 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 21:51:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=C2=B7B06):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=20[=EC=A0=80=EC=9E=A5]=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EA=B3=A0=20=3D=20=EC=84=9C=EB=B2=84=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=EC=9C=84=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=86=90?= =?UTF-8?q?=EB=8C=90=EB=A7=8C=20(PLAN=20498-2=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20411~417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브라우저 — 노선 바뀐 [저장]의 계획고 본문 = 묶음 계획고 대 저장분 옮김(같은 carryEdits · 이 초안 첫 기준)의 다른 열쇠 · 지운 열쇠만(`gradeTouch` · `routeGradeTouch`) · 못 가르면 옛 길(노선 0 칸 통째) - 서버 — `alignment_cleared` 가 오면 관 · 구조물 · 추가 측점 차례 뒤 새 종단 편집(서버 옮김) 위에 손댐만 얹어 한 번 저장(`touched_alignment` · 얹기 실패도 되돌림 안) - follow 옮김 측점 = stations 단계와 같은 추가 측점(`plus` 넘김) - 재현: 꼬리 추가 측점 ▲ 뒤 앞 교점 끌기 [저장] = 990 m 편집 버림 · ▲ 잃음 · 최대 −0.50 m → 고친 뒤 버림 0 · 끌 때 = 저장 뒤 - 시험: test_498_2_grade_touch 새로 · helper_design_store_save 에 묶음 창구 가짜 하나(옛 길) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_page.ts | 15 +- .../design/design_pipeline_page_saved.ts | 5 + A00_Common/design/design_save.ts | 22 ++- A00_Common/design/design_save_api.ts | 2 +- .../logic/B05_Profile_Pipeline_Grade.ts | 68 ++++++++- B06_Section/B06_Section_Router_DesignSave.py | 28 +++- .../B06_Section_Router_DesignSave_Route.py | 55 ++++++- resources/tester/helper_498_2_grade_touch.cjs | 144 ++++++++++++++++++ resources/tester/helper_design_store_save.cjs | 2 + resources/tester/test_498_2_grade_touch.py | 132 ++++++++++++++++ 10 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 resources/tester/helper_498_2_grade_touch.cjs create mode 100644 resources/tester/test_498_2_grade_touch.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index f5531df3f..f34f77075 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -19,7 +19,7 @@ * 초안이 있으면 측점을 냄(새로고침 · 다시 열기 = 초안 측점 · 466). * 노선 초안을 버리면(되돌리기 · [저장 전으로]) 초안 전 상세 · 횡단 초안 칸 그대로(349-0k · 349-0o) · 가게 측점 · * 종단 = 그 상세 측점 · 저장 기준(466) · 그 뒤 같은 측점 목록은 가게에 다시 안 냄(상세 측점이 묶음과 달라도 · 349-0q). - * [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄. + * [저장]이 서버 정본 — 노선 바뀐 [저장]의 계획고 = 저장분 옮김 대 손댐만(`routeGradeTouch` · 서버가 자기 옮김 위에 · 498-2). * ========================================================================== */ import { createDesignPipeline, type DesignPipeline } from "./logic/design_pipeline"; @@ -91,6 +91,7 @@ import { createPlanlineStage, gradeRange, routeRange, + type GradeTouch, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; import { groundPviAt, profileAt } from "./design_calc_profile"; import { @@ -127,6 +128,7 @@ interface PagePipeline { detail(): SectionDetailResponse | null; /** 노선 초안 버림 동안(그리기 멈춤 ~ 풂) — 저장 노선 낡음 다시 셈을 안 띄움(472-5 덧). */ holding(): boolean; + touch(): Promise; commit(): void; dispose(): void; } @@ -137,6 +139,9 @@ type Facilities = Pick; const pages = new Map(); const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); +/** 노선 바뀐 [저장]의 계획고 손댐(498-2) — 묶음이 없거나 못 가르면 null(본문 = 노선 0 칸 통째 · 옛 길). */ +export const routeGradeTouch = async (id: string) => (await pages.get(id)?.touch()) ?? null; + /** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */ export function routeDraftBridge( projectId: string, @@ -271,7 +276,12 @@ function createPagePipeline( route.curves, (patch.pipes ?? inputs.pipes ?? []) as PlanPipe[], (patch.structures ?? inputs.structures ?? []) as PlanStructure[], - { intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) }, + { + intervalM: options.stationIntervalM, + types: enabled, + ...snapOptions(inputs.route, plan), + plus: patch.plusStations ?? inputs.plusStations ?? undefined, // stations 단계와 같게(498-2) + }, ).map((station) => station.chainageM), }); /** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */ @@ -678,6 +688,7 @@ function createPagePipeline( ownsCross: Boolean(options.savedCross), detail: () => draftDetailOf(projectId), holding: () => holding, + touch: async () => (await pipe.settled(), followStage.touch(pageSaved.storedEdits(), pipe)), commit: () => void (beforeDraft = null), dispose() { disposed = true; diff --git a/A00_Common/design/design_pipeline_page_saved.ts b/A00_Common/design/design_pipeline_page_saved.ts index 150fd3e51..dc007cba7 100644 --- a/A00_Common/design/design_pipeline_page_saved.ts +++ b/A00_Common/design/design_pipeline_page_saved.ts @@ -82,6 +82,11 @@ export function createPageSaved( savedSource, canonChanged, savedEdits, + /** 서버 저장분 계획고 편집(세션 초안 뺌) — 노선 바뀐 [저장]에서 서버가 옮기는 그 값(498-2). */ + storedEdits(): AlignmentEdits { + const longitudinal = canonDetail()?.longitudinal; + return (longitudinal && readAlignment(longitudinal)?.edits) ?? emptyEdits(); + }, storedPvi, crossNow, drafted, diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index a9722939c..ebf731acc 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -11,7 +11,7 @@ * 노선(346-3 · 6-B) — 노선 초안이 마지막 저장과 다르면 같은 본문에 `route`(계약 `RouteDraft`) · `carry`(옛 노선 * 세션 횡단 고침 원본)를 실음. 서버가 맨 앞에서 노선 저장(관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 * 이월) → 관 · 구조물 초안 다시 얹기 → 새 노선으로 위 차례 → 횡단 = 세션 고침을 측점 열쇠로 넘긴 patch(같은 TS · - * Node). 이 때 조각은 기본 조각만(화면 몫 일 = 옛 노선 판이라 건너뜀) · 계획고 초안 = 노선 초안 칸 ?? 옛 노선 칸 · + * Node). 이 때 조각은 기본 조각만(화면 몫 일 = 옛 노선 판이라 건너뜀) · 계획고 = 손댐만(서버 옮김 위에 · 498-2) · * 손 상단측은 안 보냄(요구 274) · [확정]도 [저장]으로(새 노선을 본 뒤 다시 [확정]) · 다 저장하면 페이지 다시 세움. * * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 @@ -49,6 +49,8 @@ import { } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan"; import { designSignature, markDesignClean } from "./design_leave"; +import { routeGradeTouch } from "./design_pipeline_page"; +import type { GradeTouch } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; export interface DesignSaveContext { projectId: string; @@ -166,10 +168,12 @@ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }) /** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값(sub7). */ const DRAFT_ROUTE_ID = 0; -/** 계획고 조각 — 노선이 바뀌면 노선 초안 칸(`DRAFT_ROUTE_ID`) ?? 옛 노선 칸의 초안(서버가 새 노선 번호로 고쳐 - * 씀 · 측점이 없는 자리는 버리고 알림 · 213-2) · 저장 뒤 두 칸 다 비움. */ -function alignmentPart(routeId: number, moved: boolean): SavePart | null { - const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(routeId); +/** 계획고 조각 — 노선이 바뀌면 사용자 손댐(`touch` · 서버가 자기 옮김 위에 얹음 · 498-2) · 못 가르면 노선 초안 + * 칸(`DRAFT_ROUTE_ID`) ?? 옛 노선 칸의 초안 통째(서버가 새 노선 번호로 고쳐 씀) · 측점이 없는 자리는 버리고 알림 + * (213-2) · 저장 뒤 두 칸 다 비움. */ +function alignmentPart(routeId: number, moved: boolean, touch: GradeTouch | null): SavePart | null { + const draft = + touch?.edits ?? (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(routeId); if (!draft) return null; return { body: { route_id: routeId, ...draft }, @@ -188,6 +192,7 @@ async function defaultPart( routeId: number, confirm: boolean, moved: boolean, + touch: GradeTouch | null, ): Promise { switch (key) { case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448) @@ -199,7 +204,7 @@ async function defaultPart( case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) return plusStationsPart(projectId); case "alignment": - return alignmentPart(routeId, moved); + return alignmentPart(routeId, moved, touch); case "culvertOptions": return culvertOptionsPart(stateKey("culvertopt", projectId, routeId)); case "uphill": @@ -257,12 +262,13 @@ export async function saveDesign( const parts: Partial> = {}; // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 const carried = route ? routeCarryPart(ctx.projectId, routeId) : null; + const touch = route ? await routeGradeTouch(ctx.projectId) : null; for (const key of SAVE_PART_KEYS) { if (key === "cross") { parts.cross = carried ? carried.cross : await ctx.crossPart(confirm); continue; } - const base = await defaultPart(key, ctx.projectId, routeId, confirm, !!route); + const base = await defaultPart(key, ctx.projectId, routeId, confirm, !!route, touch); const own = route ? undefined : ctx.parts?.[key]; parts[key] = own ? await own(base, confirm) : base; } @@ -272,7 +278,7 @@ export async function saveDesign( routeId, confirm, parts, - route ? { route, carry: carried?.carry } : {}, + route ? { route, carry: carried?.carry, alignment_cleared: touch?.cleared } : {}, ); if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); // 저장 도중 고침(가림막 동안 키 · 되돌리기 포함 · 505-2) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸 diff --git a/A00_Common/design/design_save_api.ts b/A00_Common/design/design_save_api.ts index 952832ff6..fc6d9bc6b 100644 --- a/A00_Common/design/design_save_api.ts +++ b/A00_Common/design/design_save_api.ts @@ -69,7 +69,7 @@ export async function postDesignSave( routeId: number, confirm: boolean, parts: Partial>, - extra: { route?: unknown; carry?: unknown } = {}, + extra: { route?: unknown; carry?: unknown; alignment_cleared?: unknown } = {}, ): Promise { const body: Record = { route_id: routeId, confirm, ...extra }; for (const key of SAVE_PART_KEYS) { diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index be68ffe06..74b016409 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -5,7 +5,8 @@ * * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` * `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) · - * 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3). + * 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3) · + * 노선 바뀐 [저장]의 계획고 손댐(`touch` · 저장분 옮김과 다른 것만 · 498-2). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점 * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 · * 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts` @@ -15,12 +16,14 @@ * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. * ========================================================================== */ +import type { DesignPipeline } from "../../A00_Common/design/logic/design_pipeline"; import type { FollowPatch, PipelineInputs, PipelineStation, RangePart, RouteLine, + StageContext, StageFn, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; @@ -120,11 +123,16 @@ export function createFollowStage(deps: { savedPlus?(): ReadonlyArray; /** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */ stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[]; -}): StageFn<"follow"> { +}): FollowStage { let ref: GradeRef | null = null; let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로 - return (ctx) => { - if (!ctx.inputs.route && deps.saved) return deps.saved(); + /** 이 노선 초안 첫 기준(저장 노선 · 자동 꼭짓점 · 반 간격) — 저장분 옮김을 다시 셈(498-2). */ + let first: { line: XY[]; basePvi: AlignmentBase["basePvi"]; half: number } | null = null; + const stage = (ctx: StageContext<"follow">): FollowPatch => { + if (!ctx.inputs.route && deps.saved) { + first = null; + return deps.saved(); + } const before = ctx.before.route?.line ?? deps.savedLine(); const now = ctx.out.route; if (!before || !now) return {}; @@ -163,9 +171,10 @@ export function createFollowStage(deps: { alignment && planline ? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits } : null; + const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2; + if (ref && !first) first = { line: ref.line, basePvi: ref.basePvi, half }; if (ref && ctx.before.route) { const span = changedSpan(ref.line, line); - const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2; const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : []; carried = patch.alignment = span ? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half) @@ -173,6 +182,55 @@ export function createFollowStage(deps: { } return patch; }; + return Object.assign(stage, { + touch(saved: AlignmentEdits, pipe: PipelineReader): GradeTouch | null { + const route = pipe.output("route"); + const now = pipe.input("alignment") as AlignmentEdits | null; + if (!first || !route || !now || !pipe.input("route")) return null; + const line = xy(route.line); + if (!changedSpan(first.line, line)) return gradeTouch(now, saved); + const inputs = Object.fromEntries(INPUT_KEYS.map((key) => [key, pipe.input(key)])); + const stations = + deps.stationsAt?.(route, {}, inputs as unknown as PipelineInputs) ?? gridOf(route); + return gradeTouch( + now, + carryEdits(first.line, line, first.basePvi, saved, stations, first.half), + ); + }, + }); +} + +/** 묶음 읽기 — 지금 입력 · 출력(`DesignPipeline` 의 읽는 쪽). */ +type PipelineReader = Pick; +const INPUT_KEYS = ["route", "alignment", "cross", "pipes", "structures", "plusStations"] as const; + +/** follow 단계 + 노선 바뀐 [저장]의 계획고 손댐 — 묶음 계획고 대 이 초안 첫 기준에서 `saved` 를 지금 노선 · + * 측점에 한 번에 옮긴 값(서버 노선 차례 옮김과 같은 셈) · 첫 기준이 없으면(열 때 초안 · 서버 follow) null · 498-2. */ +export type FollowStage = StageFn<"follow"> & { + touch(saved: AlignmentEdits, pipe: PipelineReader): GradeTouch | null; +}; + +/** 노선 바뀐 [저장]의 계획고 손댐(498-2) — 사용자가 고친 열쇠(값) · 지운 열쇠. */ +export interface GradeTouch { + edits: AlignmentEdits; + cleared: { station_offsets: string[]; curve_radii: string[] }; +} + +/** 묶음 계획고(`now`) 대 저장분 옮김(`carried`) — 다른 값 · 새 열쇠 = 손댐 · 옮김에만 있는 열쇠 = 지움. 서버가 자기 + * 옮김 위에 이것만 얹음(관 · 구조물 다시 얹기와 같은 꼴 · 498-2). */ +export function gradeTouch(now: AlignmentEdits, carried: AlignmentEdits): GradeTouch { + const out: GradeTouch = { + edits: emptyEdits(), + cleared: { station_offsets: [], curve_radii: [] }, + }; + for (const field of ["station_offsets", "curve_radii"] as const) { + const was = carried[field] ?? {}; + const is = now[field] ?? {}; + for (const [key, value] of Object.entries(is)) + if (!(key in was) || Math.abs(was[key] - value) > 1e-6) out.edits[field][key] = value; + out.cleared[field] = Object.keys(was).filter((key) => !(key in is)); + } + return out; } /** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */ diff --git a/B06_Section/B06_Section_Router_DesignSave.py b/B06_Section/B06_Section_Router_DesignSave.py index 1d0303c2e..f48fbecc0 100644 --- a/B06_Section/B06_Section_Router_DesignSave.py +++ b/B06_Section/B06_Section_Router_DesignSave.py @@ -36,7 +36,11 @@ from B05_Profile.B05_Profile_Schema import ( ) from B05_Profile.B05_Profile_Structures_Schema import StructureSaveRequest from B06_Section import B06_Section_Router_DesignSave_Progress as progress -from B06_Section.B06_Section_Router_DesignSave_Route import RouteCarryRequest, StepFailed +from B06_Section.B06_Section_Router_DesignSave_Route import ( + AlignmentCleared, + RouteCarryRequest, + StepFailed, +) from B06_Section.B06_Section_Router_Stations import PlusStationsRequest from B06_Section.B06_Section_Schema import SectionConfirmRequest from common_util import common_util_save_guard as guard @@ -70,6 +74,9 @@ class DesignSaveRequest(BaseModel): route: RouteSaveRequest | None = None #: 노선 바뀐 저장의 옛 노선 세션 칸(측점 열쇠 넘김 입력) carry: RouteCarryRequest | None = None + #: 노선 바뀐 저장의 계획고 지운 열쇠 — 오면 `alignment` = 사용자 손댐(서버 옮김 위에 얹음 · + #: 498-2) · 없으면 `alignment` 통째(옛 길) + alignment_cleared: AlignmentCleared | None = None def _body(response: Any) -> dict[str, Any]: @@ -115,7 +122,6 @@ async def _run_steps( get_pipe_points, put_pipe_points, ) - from B05_Profile.B05_Profile_Router import save_profile_alignment from B05_Profile.B05_Profile_Router_Lifecycle import confirm_latest_route, save_page_settings from B05_Profile.B05_Profile_Structures_Router import write_structures from B06_Section.B06_Section_Router_Confirm import confirm_sections, save_sections @@ -175,6 +181,8 @@ async def _run_steps( # 계획고 초안 = 새 노선에(기하 이월 · 서버가 없는 자리 버림) if request.alignment is not None: request.alignment = request.alignment.model_copy(update={"route_id": route_id}) + # 노선 바뀜 + 손댐 = 서버 옮김(관 · 구조물 · 추가 측점 차례 뒤 새 종단 편집) 위에 손댐만(498-2) + touched = moved and request.alignment_cleared is not None if request.settings is not None: await step("settings", save_page_settings(project_id, request.settings)) if request.pipes is not None or request.confirm: @@ -192,7 +200,12 @@ async def _run_steps( if request.plus_stations is not None: await step("plusStations", put_plus_stations(project_id, request.plus_stations)) if request.alignment is not None: - await step("alignment", save_profile_alignment(project_id, request.alignment)) + alignment = request.alignment + if touched: + alignment = moving.touched_alignment( + project_id, route_id, alignment, request.alignment_cleared + ) + await step("alignment", _save_alignment(project_id, alignment)) if request.culvert_options: stored = await step("culvertOptions", get_pipe_points(project_id)) points = _patched_points(stored, request.culvert_options) @@ -241,6 +254,15 @@ async def _run_steps( return out +async def _save_alignment(project_id: UUID, alignment: Any) -> Any: + """계획고 차례 — 본문(또는 본문을 내는 약속 · 노선 바뀐 손댐 얹기 · 498-2)을 저장.""" + from B05_Profile.B05_Profile_Router import save_profile_alignment + + if not isinstance(alignment, ProfileAlignmentSaveRequest): + alignment = await alignment + return await save_profile_alignment(project_id, alignment) + + async def _save_stages(project_id: str) -> None: """B05 · B06 한 본문 [저장] — 단계 2 · 3(실패면 한 본문 되돌림이 단계 행도 되살림).""" from common_util.common_util_workflow_state import mark_stage diff --git a/B06_Section/B06_Section_Router_DesignSave_Route.py b/B06_Section/B06_Section_Router_DesignSave_Route.py index 99901482f..86f10ef55 100644 --- a/B06_Section/B06_Section_Router_DesignSave_Route.py +++ b/B06_Section/B06_Section_Router_DesignSave_Route.py @@ -5,7 +5,8 @@ 노선 저장(`/route/replan` 체인 · 관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 · 횡단 이월) → 관 · 구조물 초안 다시 얹기(Node `facility.rebase` · 서버 따라감 위에) - → 6-A 차례(새 노선) → 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch(Node `stations.carry`) + → 6-A 차례(새 노선 · 계획고 = 서버 옮김 위에 사용자 손댐만 · 498-2) + → 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch(Node `stations.carry`) 브라우저가 하던 다시 얹기(`rebasePipeDraft` · `carryAfterRouteSave`)와 같은 TS 를 서버가 부름 — 브라우저는 노선 초안 + 사용자 칸만 보냄. 손 상단측은 안 넘김(요구 274 · 새 지형이 정함). @@ -36,6 +37,15 @@ class RouteCarryRequest(BaseModel): pipe_ids: dict[str, Any] | None = None +class AlignmentCleared(BaseModel): + """노선 바뀐 [저장]에서 사용자가 지운 계획고 열쇠(498-2 · 브라우저 `gradeTouch`).""" + + model_config = ConfigDict(extra="forbid") + + station_offsets: list[str] = Field(default_factory=list) + curve_radii: list[str] = Field(default_factory=list) + + class StepFailed(Exception): def __init__(self, step: str, message: str) -> None: super().__init__(message) @@ -121,3 +131,46 @@ async def carry_cross( **(carry or RouteCarryRequest()).model_dump(mode="json"), } return _node(["stations.carry"], {"carry": inputs}, "cross") + + +def overlay_edits( + carried: dict[str, Any], touch: dict[str, Any], cleared: AlignmentCleared +) -> dict[str, dict[str, float]]: + """서버 옮김 편집 위에 사용자 손댐만(498-2) — 지운 · 고친 열쇠 자리(0.006 m 안)의 옮김 값을 걷고 + 손댐 값을 얹음. 손대지 않은 옮김 열쇠는 서버 측점 그대로(브라우저 열쇠로 안 덮음).""" + from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M + + out: dict[str, dict[str, float]] = {} + for field in ("station_offsets", "curve_radii"): + merged = {str(key): float(value) for key, value in (carried.get(field) or {}).items()} + mine = {str(key): float(value) for key, value in (touch.get(field) or {}).items()} + for key in [*getattr(cleared, field), *mine]: + for near in [k for k in merged if abs(float(k) - float(key)) < SAME_STATION_SNAP_M]: + del merged[near] + out[field] = {**merged, **mine} + return out + + +async def touched_alignment( + project_id: UUID, route_id: int, touch: Any, cleared: AlignmentCleared +) -> Any: + """노선 바뀐 [저장] 계획고 본문(498-2) = 새 종단 편집(노선 차례 서버 옮김) ⊕ 사용자 손댐.""" + import json + from pathlib import Path + + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_Profile.B05_Profile_Schema import ProfileAlignmentSaveRequest + from B06_Section.B06_Section_Repository import get_longitudinal_section + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import get_db_pool + + async with get_db_pool().acquire() as connection: + row = await get_longitudinal_section(connection, project_id, route_id) + stored = await get_project_storage_relative_path(connection, project_id) + if not row: + raise StepFailed("alignment", "새 노선 종단을 못 읽었습니다") + path = Path(resolve_stored_project_path(stored)) / str(row["longitudinal_file_path"]) + longitudinal = json.loads(path.read_text(encoding="utf-8")) + carried = (longitudinal.get("profile_alignment") or {}).get("edits") or {} + edits = overlay_edits(carried, touch.edits() if touch is not None else {}, cleared) + return ProfileAlignmentSaveRequest(route_id=route_id, **edits) diff --git a/resources/tester/helper_498_2_grade_touch.cjs b/resources/tester/helper_498_2_grade_touch.cjs new file mode 100644 index 000000000..270397932 --- /dev/null +++ b/resources/tester/helper_498_2_grade_touch.cjs @@ -0,0 +1,144 @@ +/* 498-2 노선 바뀐 [저장] 계획고 손댐 — 진짜 TS(`logic/B05_Profile_Pipeline_Grade` follow · `gradeTouch`)를 + * Node 로 돌림. 표준출력 JSON 한 벌. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + return makeLoader({ + root: ROOT, + cache: false, + requireFor: (full) => (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }, + }).loadTs(path.join(ROOT, file)); +} + +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, + "./B05_Profile_Profile_CurveLaw": curveLaw, +}); +const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); +const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", { + "./B05_Profile_Profile_CurveLaw": curveLaw, + "../../common_util/common_util_route_follow": {}, + "../../common_util/common_util_structure_station": { + PLUS_OWNER_PREFIX: "plus-", + SAME_STATION_M: 0.005, + }, + "./B05_Profile_Grade_Seat": loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, + }), + "./B05_Profile_UI_Profile_Alignment": alignment, + "./B05_Profile_Grade_Carry": carry, + "./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 }, + "./B05_Profile_Grade_Ground": loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { + "./B05_Profile_Profile_CurveLaw": curveLaw, + "./B05_Profile_UI_Profile_Alignment": alignment, + }), +}); + +const routeOf = (line) => { + const chainage = [0]; + for (let i = 1; i < line.length; i += 1) + chainage.push( + chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]), + ); + return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] }; +}; +/** 10 m 격자 + 끝 — 묶음 stations 단계 자리. */ +const gridOf = (route) => { + const out = []; + for (let s = 0; s < route.lengthM - 1e-6; s += 10) out.push(Math.round(s * 1000) / 1000); + return [...out, Math.round(route.lengthM * 1000) / 1000]; +}; + +const SAVED_LINE = [ + [0, 0], + [100, 0], + [100, 100], + [100, 200], +]; +const STEPS = [ + [ + [0, 0], + [95, 0], + [100, 100], + [100, 200], + ], + [ + [0, 0], + [90, 0], + [100, 100], + [100, 200], + ], +]; +const BASE = [0, 100, 200, 300].map((chainage_m) => ({ chainage_m, elevation_m: 500 })); +const SAVED = { station_offsets: { "250.000": 0.5 }, curve_radii: {} }; + +/** 저장 노선에서 노선 걸음 둘 — 걸음 사이 `between` 이 사용자 계획고 조작(묶음 입력 고침). */ +function run(between, after) { + const stage = grade.createFollowStage({ + savedLine: () => null, + stationsAt: (route) => gridOf(route), + }); + const planline = { policy: { station_interval_m: 20 }, basePvi: BASE }; + let now = SAVED; + let before = routeOf(SAVED_LINE); + let route = before; + STEPS.forEach((line, index) => { + route = routeOf(line); + const patch = stage({ + inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: now }, + out: { route }, + before: { route: before, planline }, + }); + if (patch.alignment) now = patch.alignment; + if (index === 0 && between) now = between(now); + before = route; + }); + if (after) now = after(now); + const reader = { + input: (key) => ({ route: { nodes: [] }, alignment: now })[key] ?? null, + output: (key) => (key === "route" ? route : null), + }; + return { now, touch: stage.touch(SAVED, reader) }; +} + +const bump = (key, by) => (edits) => ({ + ...edits, + station_offsets: { ...edits.station_offsets, [key]: (edits.station_offsets[key] ?? 0) + by }, +}); +const drop = (edits) => { + const offsets = { ...edits.station_offsets }; + delete offsets[Object.keys(offsets).sort()[0]]; + return { ...edits, station_offsets: offsets }; +}; + +process.stdout.write( + JSON.stringify({ + plain: run(null, null), + lateBump: run(null, bump("60.000", 0.3)), + midBump: run(bump("40.000", 0.2), null), + removed: run(null, drop), + noFirst: grade.createFollowStage({ savedLine: () => null }).touch(SAVED, { + input: () => null, + output: () => null, + }), + pure: grade.gradeTouch( + { station_offsets: { "10.000": 1, "20.000": 2.5 }, curve_radii: { "30.000": 80 } }, + { station_offsets: { "10.000": 1, "20.000": 2, "40.000": 3 }, curve_radii: {} }, + ), + }), +); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index d77b34df1..e4ce00bec 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -119,6 +119,8 @@ const STUBS = { // 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림 "./design_save_api": saveApiStub, "./design_leave": { designSignature: () => "", markDesignClean: () => undefined }, + // 498-2 노선 바뀐 [저장] 계획고 손댐 — 묶음 없음 = 노선 0 칸 통째(옛 길) + "./design_pipeline_page": { routeGradeTouch: async () => null }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/test_498_2_grade_touch.py b/resources/tester/test_498_2_grade_touch.py new file mode 100644 index 000000000..d9ac31dd5 --- /dev/null +++ b/resources/tester/test_498_2_grade_touch.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""498-2 노선 바뀐 [저장] 계획고 — 브라우저는 손댐(입력)만 · 서버가 자기 옮김 위에 한 번. + +- 브라우저(`B05_Profile_Pipeline_Grade` follow `touch` · `gradeTouch`) — 노선만 끌었으면 손댐 0 · + 끈 뒤 ▲ = 그 열쇠만 · 끄는 중 ▲ = 그 열쇠 포함 묶음 값 그대로 · 지운 열쇠 = `cleared` · + 첫 기준 없으면 null(옛 길 · 0 칸 통째). +- 서버(`B06_Section_Router_DesignSave_Route.overlay_edits` · `touched_alignment`) — 손대지 않은 옮김 + 열쇠는 서버 측점 그대로 · 고친 · 지운 열쇠 자리(0.006 m 안)만 걷고 손댐 값을 얹음 · 브라우저 측점 + 열쇠로 서버 열쇠를 덮지 않음(옛 흠 = 두 쪽 측점이 다르면 어긋난 편집을 지움). +""" + +import asyncio +import json +import subprocess +from pathlib import Path + +import pytest + +from B06_Section import B06_Section_Router_DesignSave_Route as moving + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_498_2_grade_touch.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _empty(touch: dict) -> bool: + return not any(touch["edits"].values()) and not any(touch["cleared"].values()) + + +def test_노선만_끌면_손댐_0(ts): + assert ts["plain"]["now"]["station_offsets"], "옮긴 편집이 있어야 시험이 뜻을 가짐" + assert _empty(ts["plain"]["touch"]) + + +def test_끈_뒤_올림은_그_열쇠만(ts): + touch = ts["lateBump"]["touch"] + assert touch["edits"]["station_offsets"] == {"60.000": 0.3} + assert not any(touch["cleared"].values()) + + +def test_끄는_중_올림은_묶음_값_그대로(ts): + now, touch = ts["midBump"]["now"], ts["midBump"]["touch"] + assert touch["edits"]["station_offsets"]["40.000"] == pytest.approx(0.2) + for key, value in touch["edits"]["station_offsets"].items(): + assert now["station_offsets"][key] == value + + +def test_지운_열쇠는_cleared(ts): + touch = ts["removed"]["touch"] + assert touch["cleared"]["station_offsets"] == ["200.000"] + assert not any(touch["edits"].values()) + + +def test_첫_기준_없으면_null_옛_길(ts): + assert ts["noFirst"] is None + + +def test_gradeTouch_순수(ts): + assert ts["pure"] == { + "edits": {"station_offsets": {"20.000": 2.5}, "curve_radii": {"30.000": 80}}, + "cleared": {"station_offsets": ["40.000"], "curve_radii": []}, + } + + +def test_서버_옮김_위에_손댐만(): + carried = { + "station_offsets": {"960.000": 0.005, "970.000": 0.64, "980.000": 0.014}, + "curve_radii": {"500.000": 120.0}, + } + touch = {"station_offsets": {"970.003": 0.7, "1000.000": 0.2}, "curve_radii": {}} + cleared = moving.AlignmentCleared(station_offsets=["980.000"], curve_radii=["500.004"]) + assert moving.overlay_edits(carried, touch, cleared) == { + # 960 = 손 안 댐(서버 값) · 970 = 0.006 m 안 손댐이 갈음 · 980 지움 · 1000 새 손댐 + "station_offsets": {"960.000": 0.005, "970.003": 0.7, "1000.000": 0.2}, + "curve_radii": {}, + } + + +def test_손댐_0이면_서버_옮김_그대로(): + carried = {"station_offsets": {"960.000": 0.005, "970.000": 0.64}, "curve_radii": {}} + out = moving.overlay_edits(carried, {}, moving.AlignmentCleared()) + assert out == {"station_offsets": carried["station_offsets"], "curve_radii": {}} + + +def test_계획고_본문은_새_종단_편집에서(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from B03_FileInput import B03_FileInput_Repository as files + from B05_Profile.B05_Profile_Schema import ProfileAlignmentSaveRequest + from B06_Section import B06_Section_Repository as repo + from common_util import common_util_storage as storage + from config import config_db + + (tmp_path / "long.json").write_text( + json.dumps({"profile_alignment": {"edits": {"station_offsets": {"970.000": 0.64}}}}), + encoding="utf-8", + ) + + class Pool: + def acquire(self): + return self + + async def __aenter__(self): + return None + + async def __aexit__(self, *_): + return False + + async def longitudinal(_connection, _project, route_id): + assert route_id == 820, "새 노선 번호로 읽어야 함" + return {"longitudinal_file_path": "long.json"} + + async def stored(_connection, _project): + return "p" + + monkeypatch.setattr(config_db, "get_db_pool", lambda: Pool()) + monkeypatch.setattr(repo, "get_longitudinal_section", longitudinal) + monkeypatch.setattr(files, "get_project_storage_relative_path", stored) + monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path)) + touch = ProfileAlignmentSaveRequest(route_id=819, station_offsets={"1000.000": 0.2}) + body = asyncio.run(moving.touched_alignment("pid", 820, touch, moving.AlignmentCleared())) + assert body.route_id == 820 + assert body.station_offsets == {"970.000": 0.64, "1000.000": 0.2}