Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-28 22:03:38 +09:00
10 changed files with 453 additions and 20 deletions
+13 -2
View File
@@ -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<GradeTouch | null>;
commit(): void;
dispose(): void;
}
@@ -137,6 +139,9 @@ type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
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;
@@ -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,
+14 -8
View File
@@ -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<SavePart | null> {
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<Record<SavePartKey, SavePart | null>> = {};
// 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀
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) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸
+1 -1
View File
@@ -69,7 +69,7 @@ export async function postDesignSave(
routeId: number,
confirm: boolean,
parts: Partial<Record<SavePartKey, SavePart | null>>,
extra: { route?: unknown; carry?: unknown } = {},
extra: { route?: unknown; carry?: unknown; alignment_cleared?: unknown } = {},
): Promise<DesignSaveResult> {
const body: Record<string, unknown> = { route_id: routeId, confirm, ...extra };
for (const key of SAVE_PART_KEYS) {
@@ -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<FollowingStructure>;
/** 새 노선 측점 누가거리(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<DesignPipeline, "input" | "output">;
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. */
+25 -3
View File
@@ -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
@@ -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)
@@ -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: {} },
),
}),
);
@@ -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({
+132
View File
@@ -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}