fix(B05·B06): 유령 계획고 편집 근본 막음 — 옮긴 관 편집 따라감 · [저장] 때 자리 없는 편집 버림 · 알림(213-2)

- ① 관 옮김 = 편집 키도 새 자리로: B06 movePipeTo 가 resetStationEdit(옛, 새) · B05 측점 갱신이 사라진 것 하나 · 새것 하나(또는 같은 id · 0.006 m 안)를 짝으로 옮김(followedStation) — 세션 초안도 새 키라 옛 키가 다시 안 나감
- ② [저장](PUT profile-alignment)에서 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버림(drop_unplaced_edits · 저장 차례가 관 · 구조물 뒤라 최신) · 응답 dropped_edit_chainages · 화면 알림 「계획고 편집 N곳을 지웠습니다」 · 자리 못 읽으면 안 버림
- ③ test_192_1 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 460~840 m) · 새 시험 test_213_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 15:44:28 +09:00
co-authored by Claude Opus 5.5
parent 002165f040
commit ffa7f9d236
13 changed files with 2906 additions and 34 deletions
+2
View File
@@ -86,6 +86,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -650,6 +651,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
async save(): Promise<void> { async save(): Promise<void> {
if (!routeId || !store.dirty()) return; if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits()); const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined; const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) { if (next?.base_pvi?.length) {
base = toAlignmentBase(next); base = toAlignmentBase(next);
+16 -1
View File
@@ -65,11 +65,26 @@ function reportOnly(label: string): (error: unknown) => void {
}; };
} }
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
const dropped = saved.dropped_edit_chainages ?? [];
if (!dropped.length) return;
const where = dropped
.slice(0, 5)
.map((value) => `${value.toFixed(2)}m`)
.join(" · ");
showToast(
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
"info",
);
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ /** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> { async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId); const draft = pendingAlignment(routeId);
if (!draft) return; if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft); noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(routeId); markAlignmentSaved(routeId);
} }
+2
View File
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
route_id: number; route_id: number;
profile_alignment: unknown; profile_alignment: unknown;
grade_summary: RouteGradeSummary | null; grade_summary: RouteGradeSummary | null;
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
dropped_edit_chainages?: number[];
} }
/** /**
@@ -214,6 +214,30 @@ def snap_to_stations(
) )
def drop_unplaced_edits(
edits: dict[str, dict[str, float]], places: np.ndarray
) -> tuple[dict[str, dict[str, float]], list[float]]:
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
"""
kept: dict[str, dict[str, float]] = {}
dropped: set[float] = set()
for name, values in edits.items():
kept[name] = {}
for key, value in values.items():
try:
chainage = float(key)
except (TypeError, ValueError):
continue
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
kept[name][key] = value
else:
dropped.add(round(chainage, _KEY_DECIMALS))
return kept, sorted(dropped)
def resolve_pvi( def resolve_pvi(
base_s: np.ndarray, base_s: np.ndarray,
base_z: np.ndarray, base_z: np.ndarray,
+43 -6
View File
@@ -8,6 +8,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
import aiomysql import aiomysql
import numpy as np
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Debug import log_b05_debug from B05_Profile.B05_Profile_Debug import log_b05_debug
from B05_Profile.B05_Profile_Engine import run_route_design from B05_Profile.B05_Profile_Engine import run_route_design
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
@@ -384,13 +386,30 @@ async def update_contour_interval(
) )
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
extras = resolve_extra_stations(project_root, pipes)
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
def _apply_alignment_edits( def _apply_alignment_edits(
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any] project_root: Path,
) -> tuple[dict[str, Any], dict[str, Any]]: longitudinal_file_path: str,
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다. edits: dict[str, Any],
marks: list[float] | None = None,
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에 화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
""" """
root = project_root.resolve() root = project_root.resolve()
path = (root / longitudinal_file_path).resolve() path = (root / longitudinal_file_path).resolve()
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
if not path.is_file(): if not path.is_file():
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.") raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
longitudinal = json.loads(path.read_text(encoding="utf-8")) longitudinal = json.loads(path.read_text(encoding="utf-8"))
dropped: list[float] = []
if marks is not None:
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
edits, dropped = drop_unplaced_edits(
{
"station_offsets": dict(edits.get("station_offsets") or {}),
"curve_radii": dict(edits.get("curve_radii") or {}),
},
np.array(stations + marks, dtype=np.float64),
)
alignment, profile = rebuild_alignment_profile(longitudinal, edits) alignment, profile = rebuild_alignment_profile(longitudinal, edits)
longitudinal["profile_alignment"] = alignment longitudinal["profile_alignment"] = alignment
profiles = longitudinal.get("design_profiles") profiles = longitudinal.get("design_profiles")
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
else: else:
longitudinal["design_profiles"] = [profile] longitudinal["design_profiles"] = [profile]
atomic_write_json(path, longitudinal) atomic_write_json(path, longitudinal)
return alignment, {"id": profile["id"], **profile["summary"]} return alignment, {"id": profile["id"], **profile["summary"]}, dropped
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse) @router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
@@ -426,11 +455,18 @@ async def save_profile_alignment(
) )
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
alignment, grade_summary = await asyncio.to_thread( route = await get_latest_route(connection, project_id)
marks = None
if route and route["id"] == request.route_id and route.get("route_data_path"):
marks = await asyncio.to_thread(
_structure_marks, project_root, str(route["route_data_path"])
)
alignment, grade_summary, dropped = await asyncio.to_thread(
_apply_alignment_edits, _apply_alignment_edits,
project_root, project_root,
str(longitudinal["longitudinal_file_path"]), str(longitudinal["longitudinal_file_path"]),
request.edits(), request.edits(),
marks,
) )
await connection.begin() await connection.begin()
try: try:
@@ -446,6 +482,7 @@ async def save_profile_alignment(
route_id=request.route_id, route_id=request.route_id,
profile_alignment=alignment, profile_alignment=alignment,
grade_summary=grade_summary, grade_summary=grade_summary,
dropped_edit_chainages=dropped,
) )
except FileNotFoundError as exc: except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
@@ -173,7 +173,9 @@ async def apply_profile_target(
root = Path(resolve_stored_project_path(stored_path)) root = Path(resolve_stored_project_path(stored_path))
relative = str(row["longitudinal_file_path"]) relative = str(row["longitudinal_file_path"])
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target) count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {}) _alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, root, relative, {}
)
async with pool.acquire() as connection: async with pool.acquire() as connection:
await connection.begin() await connection.begin()
await update_longitudinal_grade_summary( await update_longitudinal_grade_summary(
+2
View File
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
route_id: int route_id: int
profile_alignment: dict[str, Any] profile_alignment: dict[str, Any]
grade_summary: dict[str, Any] | None = None grade_summary: dict[str, Any] | None = None
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
dropped_edit_chainages: list[float] = Field(default_factory=list)
class RouteSolveResponse(BaseModel): class RouteSolveResponse(BaseModel):
+12 -12
View File
@@ -32,6 +32,7 @@ import {
import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection { export interface PipeProjection {
@@ -103,20 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void { function applyIrregularStations(stations: IrregularStation[]): void {
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다. // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김. // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
irregularStations const gone = irregularStations.filter(
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3))) (station) => !nextKeys.has(station.chainage_m.toFixed(3)),
.forEach((station) => { );
const same = stations.find( const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
(next) => gone.forEach((station) => {
Math.abs(next.chainage_m - station.chainage_m) < 0.006 && const same = followedStation(station, gone, born);
!oldKeys.has(next.chainage_m.toFixed(3)), deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
); });
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
irregularStations = stations; irregularStations = stations;
deps.renderStationLines(); deps.renderStationLines();
deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
@@ -182,6 +182,22 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
}; };
} }
/**
* 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와
* 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움).
*/
export function followedStation<T extends { id: string; chainage_m: number }>(
station: T,
gone: ReadonlyArray<T>,
born: ReadonlyArray<T>,
): T | null {
return (
born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ??
born.find((next) => next.id === station.id) ??
(gone.length === 1 && born.length === 1 ? born[0] : null)
);
}
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */ /** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
const SAME_STATION_SNAP_M = 0.006; const SAME_STATION_SNAP_M = 0.006;
@@ -475,7 +475,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
const hit = pipeFacilities.find( const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
); );
if (hit) hit.chainage_m = to; if (hit) {
// 그 관 측점의 계획고 편집도 새 자리로 — 옛 키가 유령 꼭짓점으로 남지 않게(213-2)
deps.shared.profile?.resetStationEdit(hit.chainage_m, to);
hit.chainage_m = to;
}
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
currentChainageM = to; currentChainageM = to;
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,8 @@
769.28 에 남음 → B06(측점 769.28)과 B05(관 769.38) 두 자리에 계획고 편집이 남아 0.1m 사이 769.28 에 남음 → B06(측점 769.28)과 B05(관 769.38) 두 자리에 계획고 편집이 남아 0.1m 사이
−465% 계단 · 769.38 은 측점선 없는 꼭짓점. −465% 계단 · 769.38 은 측점선 없는 꼭짓점.
고침: 판정 폭 0.005m(측점 스냅 폭) · 측점을 다시 뜨면 사라진 측점의 꼭짓점 · 편집을 지움 고침: 판정 폭 0.005m(측점 스냅 폭) · 측점을 다시 뜨면 사라진 측점의 꼭짓점 · 편집을 지움
(반올림 차이 0.006m 안은 옮김). 사용자 storage 는 읽기만 — 사본(tmp_path)으로 재현. (반올림 차이 0.006m 안은 옮김). 자료 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json ·
226fc805 종단 460~840m · 측점 769.28 인 192 이전 상태 · 213-2) — 사용자 storage 에 기대지 않음.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,17 +23,13 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
import B05_Profile.B05_Profile_Engine_Sections as sections # noqa: E402 import B05_Profile.B05_Profile_Engine_Sections as sections # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
from B06_Section.B06_Section_Router_Stations import _missing_marks # noqa: E402 from B06_Section.B06_Section_Router_Stations import _missing_marks # noqa: E402
SOURCE = ( SOURCE = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
PROJECT_ROOT
/ "storage/1/3/226fc805-26c8-4f5a-9956-bacb200e10a5/B06_Section/longitudinal/longitudinal.json"
)
REL = "B06_Section/longitudinal/longitudinal.json" REL = "B06_Section/longitudinal/longitudinal.json"
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="검증_랩탑메인 storage 없음")
def _copy(tmp_path: Path) -> Path: def _copy(tmp_path: Path) -> Path:
target = tmp_path / REL target = tmp_path / REL
@@ -58,20 +55,18 @@ def _irregular(data: dict, old: float, new: float) -> list[dict]:
return result return result
@needs_copy
def test_원본_자료가_숨은_꼭짓점을_가짐() -> None: def test_원본_자료가_숨은_꼭짓점을_가짐() -> None:
"""까닭 근거 — 769.38 꼭짓점은 측점 목록에 없고 두 편집 사이 −465%.""" """까닭 근거 — 769.38 꼭짓점은 측점 목록에 없고 두 편집 사이 −465%."""
data = _load(SOURCE) data = _load(SOURCE)
alignment = data["profile_alignment"] alignment, _ = rebuild_alignment_profile(data, data["profile_alignment"]["edits"])
stations = [s["chainage_m"] for s in data["stations"]] stations = [s["chainage_m"] for s in data["stations"]]
pvis = [p["chainage_m"] for p in alignment["pvi"]] pvis = [p["chainage_m"] for p in alignment["pvi"]]
assert 769.38 in pvis and 769.38 not in stations and 769.28 in stations assert 769.38 in pvis and 769.38 not in stations and 769.28 in stations
assert set(alignment["edits"]["station_offsets"]) == {"769.280", "769.380"} assert {"769.280", "769.380"} <= set(alignment["edits"]["station_offsets"])
step = next(s for s in alignment["segments"] if s["from_m"] == 769.28) step = next(s for s in alignment["segments"] if s["from_m"] == 769.28)
assert step["to_m"] == 769.38 and step["grade_percent"] < -400 assert step["to_m"] == 769.38 and step["grade_percent"] < -400
@needs_copy
def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Path) -> None: def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Path) -> None:
path = _copy(tmp_path) path = _copy(tmp_path)
data = _load(path) data = _load(path)
@@ -90,7 +85,6 @@ def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Pa
assert after["design_profiles"][0]["summary"]["max_grade_pct"] == summary["max_grade_pct"] assert after["design_profiles"][0]["summary"]["max_grade_pct"] == summary["max_grade_pct"]
@needs_copy
def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None: def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
path = _copy(tmp_path) path = _copy(tmp_path)
data = _load(path) data = _load(path)
@@ -101,7 +95,6 @@ def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
assert "484.210" not in offsets and offsets["484.213"] == pytest.approx(0.3) assert "484.210" not in offsets and offsets["484.213"] == pytest.approx(0.3)
@needs_copy
def test_측점이_그대로면_계획선을_건드리지_않음(tmp_path: Path) -> None: def test_측점이_그대로면_계획선을_건드리지_않음(tmp_path: Path) -> None:
path = _copy(tmp_path) path = _copy(tmp_path)
data = _load(path) data = _load(path)
@@ -0,0 +1,183 @@
"""213-2 — 유령 계획고 편집 근본 막음.
① 관을 옮기면 편집 키가 새 자리로 따라감 — 화면 `followedStation`
(B05_Profile_UI_Profile_Alignment.ts · B05 applyIrregularStations) ·
B06 movePipeTo 는 resetStationEdit(옛, 새).
② [저장] 때 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버리고 자리 목록을 돌려줌
(`drop_unplaced_edits` · `_apply_alignment_edits(marks=...)`).
자료 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 종단 460~840m).
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits # noqa: E402
from B05_Profile.B05_Profile_Router import _apply_alignment_edits # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
FIXTURE = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
REL = "B06_Section/longitudinal/longitudinal.json"
GHOST = {"station_offsets": {"769.280": 0.506058, "769.380": 0.051428}, "curve_radii": {}}
def test_drop_keeps_station_and_marks_only():
edits = {
"station_offsets": {"20.000": 1.0, "263.206": 0.5, "263.310": 0.9, "400.000": 0.2},
"curve_radii": {"263.206": 300.0, "500.000": 80.0},
}
kept, dropped = drop_unplaced_edits(edits, np.array([0.0, 20.0, 263.21, 263.31]))
# 263.206 = 측점 263.21 의 0.006m 안(반올림) · 263.31 = 관 자리 → 남음
assert kept["station_offsets"] == {"20.000": 1.0, "263.206": 0.5, "263.310": 0.9}
assert kept["curve_radii"] == {"263.206": 300.0}
assert dropped == [400.0, 500.0]
def _project(tmp_path: Path, station_m: float) -> Path:
"""고정 사본 — 측점 769.28(옮긴 관, 측점 안 다시 뜸) 또는 769.38(다시 뜸 · 옛 꼭짓점 없음)."""
data = json.loads(FIXTURE.read_text(encoding="utf-8"))
if station_m != 769.28:
for station in data["stations"]:
if abs(station["chainage_m"] - 769.28) < 1e-6:
station.update(chainage_m=station_m, station_id="station_000000769380")
base = data["profile_alignment"]["base_pvi"]
data["profile_alignment"]["base_pvi"] = [
node for node in base if abs(node["chainage_m"] - 769.28) > 1e-6
]
target = tmp_path / REL
target.parent.mkdir(parents=True)
target.write_text(json.dumps(data), encoding="utf-8")
return target
def test_save_drops_ghost_after_station_regenerated(tmp_path):
"""226fc805 꼴 — 측점 769.38 로 다시 섰는데 세션 초안이 옛 키 769.280 을 다시 보냄."""
path = _project(tmp_path, 769.38)
alignment, summary, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, [769.38])
assert dropped == [769.28]
assert alignment["edits"]["station_offsets"] == {"769.380": 0.051428}
near = [p["chainage_m"] for p in alignment["pvi"] if 760 < p["chainage_m"] < 780]
assert near == [769.38]
assert summary["max_grade_pct"] < 100
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
assert "769.280" not in saved["edits"]["station_offsets"]
def test_save_keeps_moved_pipe_edit_before_regeneration(tmp_path):
"""관만 옮기고 측점은 옛 자리(769.28) — 옛 측점 편집 · 새 관 자리 편집 둘 다 지킴."""
_project(tmp_path, 769.28)
alignment, _, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, [769.38])
assert dropped == []
assert set(alignment["edits"]["station_offsets"]) == {"769.280", "769.380"}
def test_save_without_marks_drops_nothing(tmp_path):
"""노선이 안 맞아 관 · 구조물 자리를 못 읽으면 버리지 않음(잃는 쪽보다 남는 쪽)."""
_project(tmp_path, 769.38)
_, _, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, None)
assert dropped == []
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { followedStation } from "./B05_Profile_UI_Profile_Alignment.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = cases.map((c) => followedStation(c.station, c.gone, c.born)?.chainage_m ?? null);
writeFileSync(process.argv[3], JSON.stringify(out));
"""
ST = lambda sid, m: {"id": sid, "chainage_m": m} # noqa: E731
CASES = [
# 옮긴 관 하나(키 한 번 +0.1) → 따라감
(
{
"station": ST("pipe-263.21", 263.21),
"gone": [ST("pipe-263.21", 263.21)],
"born": [ST("pipe-263.31", 263.31)],
},
263.31,
),
# 반올림(263.206 → 263.21) → 따라감
(
{
"station": ST("pipe-263.21", 263.206),
"gone": [ST("pipe-263.21", 263.206)],
"born": [ST("pipe-263.21", 263.21)],
},
263.21,
),
# A군 구조물 = 같은 id 로
(
{
"station": ST("s1", 100.0),
"gone": [ST("s1", 100.0), ST("pipe-5.00", 5.0)],
"born": [ST("pipe-9.00", 9.0), ST("s1", 130.0)],
},
130.0,
),
# 둘이 한꺼번에 바뀜(지움 + 새로 놓음 둘) → 안 따라감(지움)
(
{
"station": ST("pipe-5.00", 5.0),
"gone": [ST("pipe-5.00", 5.0), ST("pipe-7.00", 7.0)],
"born": [ST("pipe-50.00", 50.0), ST("pipe-70.00", 70.0)],
},
None,
),
# 지우기만 → 지움
({"station": ST("pipe-5.00", 5.0), "gone": [ST("pipe-5.00", 5.0)], "born": []}, None),
]
def test_followed_station_ts(tmp_path):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
src, dst = tmp_path / "in.json", tmp_path / "out.json"
src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
assert json.loads(dst.read_text(encoding="utf-8")) == [want for _, want in CASES]
def test_move_sites_follow_edits():
"""옮김 자리 둘이 편집을 따라 옮김 — B06 movePipeTo · B05 측점 갱신(문자열 짝 확인)."""
b06 = (PROJECT_ROOT / "B06_Section/B06_Section_UI_Page_Structures_Panel.ts").read_text(
encoding="utf-8"
)
b05 = (PROJECT_ROOT / "B05_Profile/B05_Profile_UI_Page_Structures.ts").read_text(
encoding="utf-8"
)
assert "deps.shared.profile?.resetStationEdit(hit.chainage_m, to)" in b06
assert "followedStation(station, gone, born)" in b05