fix(452 서버): 구 구조물 이관은 저장 종단만 · 관 PUT 뒤 옛 횡단 파일 지움 · 시종점 열쇠 한 규칙

- 433-17① 이관 원천 = 저장 종단 파일 비정규 측점(서버가 직접 읽음) · 화면은 노선 id 만 보냄 — 초안 관 자리가 「기타」로 안 섞임
- 433-10 구조물 측점 다시 뜸(관 PUT · 측점 만들기) 끝에 옛 자리 cross_*.json 정리
- 433-18 서버 station_keys = 맨 앞 · 맨 끝 end:BP/EP 먼저 · 구간형 :start · :end · B06 stationKeysOf 도 맨 앞 · 맨 끝 end — 거울 시험 test_452

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:32:29 +09:00
co-authored by Claude Opus 5.5
parent d0779af302
commit 5478eb1f88
12 changed files with 435 additions and 40 deletions
@@ -126,17 +126,15 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
try { try {
renderSections(detail, routeId); renderSections(detail, routeId);
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). 측점은 서버가 저장
const legacy = detail.longitudinal.stations // 종단 파일에서 읽음 — 이 상세는 초안이 섞일 수 있어 보내지 않음(452).
.filter((station) => station.kind === "irregular") const hasLegacy = detail.longitudinal.stations.some(
.map((station) => ({ (station) => station.kind === "irregular",
chainage_m: station.chainage_m, );
structure: station.structure ?? "",
}));
const migrateKey = `${projectId}:${routeId}`; const migrateKey = `${projectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) { if (hasLegacy && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey); migratedRoutes.add(migrateKey);
void migrateLegacyStations(projectId, legacy) void migrateLegacyStations(projectId, routeId)
.then((result) => { .then((result) => {
if (result.migrated > 0) { if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
+4 -3
View File
@@ -313,14 +313,15 @@ export async function saveStructures(
} }
/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점 /** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */ * 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다.
* 측점은 서버가 저장 종단 파일에서 읽음 — 화면 상세(초안 섞임)를 안 보냄(452). */
export async function migrateLegacyStations( export async function migrateLegacyStations(
projectId: string, projectId: string,
stations: Array<{ chainage_m: number; structure: string }>, routeId: number,
): Promise<{ status: string; migrated: number; revision: number }> { ): Promise<{ status: string; migrated: number; revision: number }> {
return requestJson(`/projects/${projectId}/route/structures/migrate`, { return requestJson(`/projects/${projectId}/route/structures/migrate`, {
method: "POST", method: "POST",
body: JSON.stringify({ stations }), body: JSON.stringify({ route_id: routeId }),
}); });
} }
+19 -12
View File
@@ -93,19 +93,25 @@ def add_crossing_pipes(
def _facility_owners( def _facility_owners(
pipes: Sequence[PipePoint], structures: Sequence[Any] pipes: Sequence[PipePoint], structures: Sequence[Any]
) -> dict[float, tuple[str, str | None]]: ) -> dict[float, tuple[str, str | None, str]]:
"""측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙).""" """측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
owners: dict[float, tuple[str, str | None]] = {} (`plan_stations` 이름 규칙)."""
owners: dict[float, tuple[str, str | None, str]] = {}
for pipe in pipes: for pipe in pipes:
for mark in plan_stations([pipe], []): for mark in plan_stations([pipe], []):
owners.setdefault( owners.setdefault(
round(float(mark["chainage_m"]), 2), ("pipe", getattr(pipe, "id", None)) round(float(mark["chainage_m"]), 2),
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
) )
for item in structures: for item in structures:
for mark in plan_stations([], [item]): for mark in plan_stations([], [item]):
owners.setdefault( owners.setdefault(
round(float(mark["chainage_m"]), 2), round(float(mark["chainage_m"]), 2),
("structure", getattr(item, "structure_id", None)), (
"structure",
getattr(item, "structure_id", None),
str(mark.get("role") or "anchor"),
),
) )
return owners return owners
@@ -118,8 +124,9 @@ def station_keys(
) -> dict[str, float]: ) -> dict[str, float]:
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리. """측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) · 시종점(맨 앞 · 맨 끝 = 관 · 구조물이 얹혀도 `end:BP/EP` · 화면 `buildStations` · B06
id 없는 관 · 구조물은 `at:` · `stationKeysOf` 와 같음 · 452) > 관 · 구조물 자리(구간형 시작 · 끝 `:start` · `:end`) >
곡선 측점(`curve:` · 252-2) > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음). 같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음).
""" """
owners = _facility_owners(pipes, structures) owners = _facility_owners(pipes, structures)
@@ -128,14 +135,14 @@ def station_keys(
out: dict[str, float] = {} out: dict[str, float] = {}
for index, chainage in enumerate(values): for index, chainage in enumerate(values):
owner = owners.get(round(chainage, 2)) owner = owners.get(round(chainage, 2))
if owner is not None: if index == 0:
key = station_key(owner[0], chainage, interval_m, owner[1])
elif rows[index].get("kind") == "curve":
key = station_key("curve", chainage, interval_m)
elif index == 0:
key = station_key("bp", chainage, interval_m) key = station_key("bp", chainage, interval_m)
elif index == len(values) - 1: elif index == len(values) - 1:
key = station_key("ep", chainage, interval_m) key = station_key("ep", chainage, interval_m)
elif owner is not None:
key = station_key(owner[0], chainage, interval_m, owner[1], role=owner[2])
elif rows[index].get("kind") == "curve":
key = station_key("curve", chainage, interval_m)
elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M: elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M:
key = station_key("regular", chainage, interval_m) key = station_key("regular", chainage, interval_m)
else: else:
+40 -4
View File
@@ -9,6 +9,7 @@
""" """
import asyncio import asyncio
import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -16,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route from B05_Profile.B05_Profile_Repository import get_latest_route
@@ -239,9 +240,43 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
class StructureMigrateRequest(BaseModel): class StructureMigrateRequest(BaseModel):
"""구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로.""" """구 비정규 측점 이관 요청 — 노선 id 만(없으면 최신 노선).
stations: list[dict[str, Any]] = Field(default_factory=list) 측점은 서버가 저장 종단 파일에서 읽음(452)."""
route_id: int | None = None
async def _saved_irregular_stations(
project_id: UUID, root: str, route_id: int | None
) -> list[dict[str, Any]]:
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안
(옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path
pool = get_db_pool()
async with pool.acquire() as connection:
if route_id is None:
latest = await get_latest_route(connection, project_id)
route_id = latest["id"] if latest else None
row = (
await get_longitudinal_section(connection, project_id, route_id)
if route_id is not None
else None
)
if not row or not row.get("longitudinal_file_path"):
return []
try:
path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"]))
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return []
return [
{"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""}
for station in stations
if station.get("kind") == "irregular" and station.get("chainage_m") is not None
]
@router.post("/{project_id}/route/structures/migrate", response_model=None) @router.post("/{project_id}/route/structures/migrate", response_model=None)
@@ -267,7 +302,8 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
plus_chainages(root), plus_chainages(root),
) )
] ]
plan = migrate_irregular_stations(payload.stations, planted) legacy = await _saved_irregular_stations(project_id, root, payload.route_id)
plan = migrate_irregular_stations(legacy, planted)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
@@ -247,6 +247,7 @@ async def _regenerate_structure_stations(
_section_options_from_stored, _section_options_from_stored,
drop_rows_off_stations, drop_rows_off_stations,
) )
from B06_Section.B06_Section_Router_Confirm import _prune_cross_files
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot) stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
@@ -269,6 +270,8 @@ async def _regenerate_structure_stations(
summary = _merge_irregular_into_longitudinal( summary = _merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations project_root, str(longitudinal["longitudinal_file_path"]), stations
) )
# 측점을 고친 그 자리에서 옛 자리 cross_*.json 도 지움(관 PUT · 452)
_prune_cross_files(project_root, str(longitudinal["longitudinal_file_path"]))
return len(stations), summary return len(stations), summary
made, summary = await asyncio.to_thread(_regenerate) made, summary = await asyncio.to_thread(_regenerate)
+6 -3
View File
@@ -148,7 +148,8 @@ export function useStructureIds(
const looksLikePipe = (section: CrossSection): boolean => const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). */ /** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
export function stationKeysOf( export function stationKeysOf(
projectId: string | null, projectId: string | null,
sections: ReadonlyArray<CrossSection>, sections: ReadonlyArray<CrossSection>,
@@ -156,10 +157,12 @@ export function stationKeysOf(
): Keyed[] { ): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : []; const known = projectId ? (owners.get(projectId) ?? []) : [];
const planned = known.some((owner) => owner.kind === "pipe"); const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section) => { const keys = sections.map((section, index) => {
const at = section.chainage_m; const at = section.chainage_m;
let key: string; let key: string;
if (section.kind === "irregular") { if (index === 0) key = stationKey("bp", at, intervalM);
else if (index === sections.length - 1) key = stationKey("ep", at, intervalM);
else if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
else if (looksLikePipe(section)) { else if (looksLikePipe(section)) {
@@ -146,7 +146,15 @@ m.followEditorPipes(fresh2, m.createFacilityStore(), [
]); ]);
out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m])); out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m]));
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */ /* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) · 맨 앞 · 맨 끝 = 시종점(452)이라 BP · EP 사이에 둠 */
const keysOf = (project, list) =>
m
.stationKeysOf(
project,
[{ chainage_m: 0, kind: "bp" }, ...list, { chainage_m: 400, kind: "ep" }],
20,
)
.slice(1, -1);
const planned = [ const planned = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" }, { chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" }, { chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
@@ -156,9 +164,9 @@ const sections = [
{ chainage_m: 80.17, kind: "irregular" }, { chainage_m: 80.17, kind: "irregular" },
]; ];
m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]); m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]);
out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key); out.keys = keysOf(P, sections).map((k) => k.key);
m.useStationOwners(P, planned); m.useStationOwners(P, planned);
out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key); out.keysNoPipes = keysOf(P, sections).map((k) => k.key);
out.pipelineKey = m.pipelineStationKey("pipe", "p8017"); out.pipelineKey = m.pipelineStationKey("pipe", "p8017");
/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */ /* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */
@@ -171,10 +179,10 @@ const staleSections = [
{ chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} }, { chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} },
{ chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리 { chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리
]; ];
out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key); out.staleKeys = keysOf(E, staleSections).map((k) => k.key);
out.staleBook = bookSize(); out.staleBook = bookSize();
m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음 m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음
out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key; out.legacyKey = keysOf(E, [staleSections[1]])[0].key;
out.legacyBook = bookSize(); out.legacyBook = bookSize();
m.prunePipeIds([80], E); // 지금 관 자리만 남김 m.prunePipeIds([80], E); // 지금 관 자리만 남김
out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 }; out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 };
+2
View File
@@ -0,0 +1,2 @@
/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */
export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
+36
View File
@@ -0,0 +1,36 @@
/* 452 헬퍼 — B06 `stationKeysOf` 열쇠를 JSON 으로.
* node helper_452_keys.mjs <묶음.mjs> <입력.json>
* 입력 = { sections: [{chainage_m, kind}], structures: [{structure_id, chainage_m, start_m, end_m}], interval }.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
m.useStructureIds("p452", input.structures);
const sections = input.sections.map((s) => ({ station_id: `s${s.chainage_m}`, samples: [], ...s }));
process.stdout.write(JSON.stringify(m.stationKeysOf("p452", sections, input.interval)));
+3 -3
View File
@@ -123,9 +123,9 @@ def test_측점_열쇠_규칙():
"at:33.30": 33.3, # id 없는 관 "at:33.30": 33.3, # id 없는 관
"grid:2": 40.0, "grid:2": 40.0,
"pipe:p-7": 50.0, "pipe:p-7": 50.0,
"structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐 "structure:w1:start": 60.0, # 구간형 시작 · 끝 = :start · :end(452 · 화면과 한 규칙)
"at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at: "structure:w1": 77.0,
"at:80.00": 80.0, "structure:w1:end": 80.0,
"end:EP": 95.5, "end:EP": 95.5,
} }
assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음 assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음
@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""452 서버 — 구 구조물 이관 · 옛 횡단 파일 · 시종점 열쇠 (433-17① · 433-10 · 433-18).
못박는 것
- 이관 원천 = 저장 종단 파일의 비정규 측점 — 화면 본문(초안 관 자리 섞임)은 안 믿음 ·
저장 관 자리면 structures.json · pipe_points.json 그대로.
- 관 PUT 끝 측점 다시 뜸(`_regenerate_structure_stations`) = 옛 자리 cross_*.json 도 지움.
- 시종점 열쇠 한 규칙 — 맨 앞 · 맨 끝은 구조물이 얹혀도(병합이 종류를 irregular 로 갈음해도)
`end:BP/EP` · 구간형 시작 · 끝 = `:start` · `:end` · 서버 `station_keys` = B06 `stationKeysOf`.
"""
import asyncio
import json
import shutil
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B05_Profile.B05_Profile_Structures_Router as router_module # noqa: E402
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import station_keys # noqa: E402
from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402
PROJECT_ID = "45245245-2452-4524-5245-245245245245"
LONG_REL = "B06_Section/longitudinal/longitudinal.json"
def _write_longitudinal(project: Path, stations):
path = project / LONG_REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"stations": stations}), encoding="utf-8")
return path
# ── 433-17① 이관 = 저장 종단 파일 ─────────────────────────────────────────
@pytest.fixture()
def migrate_client(tmp_path, monkeypatch):
project = tmp_path / "project"
async def fake_root(project_id):
return str(project)
async def fake_length(project_id):
return 400.0
async def fake_latest(connection, project_id):
return {"id": 7}
async def fake_longitudinal(connection, project_id, route_id):
assert route_id == 7
return {"longitudinal_file_path": LONG_REL}
class _Acquire:
async def __aenter__(self):
return object()
async def __aexit__(self, *exc):
return False
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(router_module, "_route_length", fake_length)
monkeypatch.setattr(router_module, "get_latest_route", fake_latest)
monkeypatch.setattr(router_module, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
monkeypatch.setattr(b06_repo, "get_longitudinal_section", fake_longitudinal)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app), project
def test_초안_측점_본문은_버리고_저장_관_자리는_이관_안_함(migrate_client):
client, project = migrate_client
pipes = pipe_points_path_in(project)
pipes.parent.mkdir(parents=True, exist_ok=True)
pipes.write_text(
json.dumps({"route_signature": "s", "points": [{"chainage_m": 351.0, "id": "p1"}]}),
encoding="utf-8",
)
_write_longitudinal(
project,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
before = pipes.read_bytes()
# 화면이 초안(관 351 → 364) 상세를 들고 와도 — 옛 본문 꼴을 보내 봄
body = {"route_id": 7, "stations": [{"chainage_m": 364.0, "structure": "낙석방지책"}]}
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=body)
assert response.status_code == 200, response.text
assert response.json()["migrated"] == 0
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
assert pipes.read_bytes() == before
def test_저장_종단의_옛_자유글_측점은_이관(migrate_client):
client, project = migrate_client
_write_longitudinal(
project,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 260.0, "kind": "irregular", "structure": "낙석방지책"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"route_id": None}
)
assert response.status_code == 200, response.text
assert response.json()["migrated"] == 1
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
assert [(item["type_id"], item["chainage_m"]) for item in stored] == [("etc", 260.0)]
def test_저장_종단이_없으면_아무것도_안_함(migrate_client):
client, project = migrate_client
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={})
assert response.status_code == 200 and response.json()["migrated"] == 0
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
# ── 433-10 관 PUT 측점 다시 뜸 = 옛 cross 파일 지움 ─────────────────────────
def test_관_옮김_측점_다시_뜸이_옛_자리_횡단_파일을_지움(tmp_path, monkeypatch):
import B05_Profile.B05_Profile_Engine_Sections as engine
import B06_Section.B06_Section_Router_Stations as stations_module
_write_longitudinal(
tmp_path,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
cross_dir = tmp_path / "B06_Section" / "cross_sections"
cross_dir.mkdir(parents=True)
for at in (0.0, 351.0, 400.0):
(cross_dir / cross_filename(at)).write_text("{}", encoding="utf-8")
def fake_generate(*args, **kwargs):
(cross_dir / cross_filename(361.0)).write_text("{}", encoding="utf-8")
return [{"chainage_m": 361.0, "kind": "irregular", "structure": "파형강관 D800"}]
async def fake_inputs(project_id, route, snapshot):
return None, None, {"longitudinal_file_path": LONG_REL}
async def fake_run(fn, *args):
return None # DB 행 맞춤 · 등급 요약은 이 시험 밖
monkeypatch.setattr(engine, "_load_route_polyline", lambda *a: [[0.0, 0.0], [400.0, 0.0]])
monkeypatch.setattr(engine, "_load_pipe_points", lambda *a: [])
monkeypatch.setattr(engine, "resolve_extra_stations", lambda *a: [])
monkeypatch.setattr(engine, "generate_irregular_sections", fake_generate)
monkeypatch.setattr(stations_module, "_generation_inputs", fake_inputs)
monkeypatch.setattr(stations_module, "run_with_connection", fake_run)
made = asyncio.run(
stations_module._regenerate_structure_stations(
PROJECT_ID,
tmp_path,
{"id": 7, "route_data_path": "route.json"},
{"filter_key": "f", "method": "m"},
)
)
assert made == 1
assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted(
cross_filename(at) for at in (0.0, 361.0, 400.0)
)
# ── 433-18 시종점 열쇠 한 규칙 ─────────────────────────────────────────────
STRUCTURES = [
{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 80.0, "start_m": 60.0},
]
# 구간형 옹벽 끝 = 노선 끝(EP) — 병합이 EP 행 종류를 irregular 로 갈음한 저장분 꼴
SECTIONS = [
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 20.0, "kind": "regular"},
{"chainage_m": 40.0, "kind": "regular"},
{"chainage_m": 60.0, "kind": "irregular", "structure": "옹벽"},
{"chainage_m": 80.0, "kind": "irregular", "structure": "옹벽"},
{"chainage_m": 100.0, "kind": "irregular", "structure": "옹벽"},
]
def _structures_ns():
return [SimpleNamespace(**item, end_m=100.0, options={}) for item in STRUCTURES]
def test_서버_시종점_열쇠가_구조물보다_먼저():
keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
assert keys == {
"end:BP": 0.0,
"grid:1": 20.0,
"grid:2": 40.0,
"structure:w1:start": 60.0,
"structure:w1": 80.0,
"end:EP": 100.0,
}
def test_B06_열쇠가_서버와_같음(tmp_path):
work = tmp_path / "b452"
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_452_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_452_entry.js", bundle)
source = work / "input.json"
source.write_text(
json.dumps(
{
"sections": SECTIONS,
"structures": [{**item, "end_m": 100.0} for item in STRUCTURES],
"interval": 20.0,
}
),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(HERE / "helper_452_keys.mjs"), str(bundle), str(source)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
ts = {entry["key"]: entry["chainageM"] for entry in json.loads(proc.stdout)}
assert ts == station_keys(SECTIONS, [], _structures_ns(), 20.0)
def test_노선_저장_이월_EP_고침이_새_EP_에_남음():
from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs
old_keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
# 노선이 늘어 옹벽이 따라감 — 끝(EP) 110 · 시작 70 · 기준 90
moved = [
SimpleNamespace(
structure_id="w1",
type_id="retaining_wall",
chainage_m=90.0,
start_m=70.0,
end_m=110.0,
options={},
)
]
new_sections = [
{"chainage_m": at, "kind": "regular"} for at in (0.0, 20.0, 40.0, 60.0, 70.0, 80.0, 90.0)
] + [{"chainage_m": 100.0, "kind": "regular"}, {"chainage_m": 110.0, "kind": "irregular"}]
new_keys = station_keys(new_sections, [], moved, 20.0)
designs = {100.0: {"ground_type": "rock"}, 60.0: {"ground_type": "weathered"}}
inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, ("ground_type",), ())
assert inputs[110.0] == {"ground_type": "rock"} # EP 고침 = 새 EP
assert inputs[70.0] == {"ground_type": "weathered"} # 구간 시작 고침 = 따라간 시작
assert dropped == 0
+11 -1
View File
@@ -30,7 +30,13 @@ def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, rou
monkeypatch.setattr(router_module, "_project_root", fake_project_root) monkeypatch.setattr(router_module, "_project_root", fake_project_root)
monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate) monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate)
async def fake_saved_irregular(project_id, root, route_id):
# 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌
return _migrate_payload()["stations"]
monkeypatch.setattr(router_module, "_route_length", fake_route_length) monkeypatch.setattr(router_module, "_route_length", fake_route_length)
monkeypatch.setattr(router_module, "_saved_irregular_stations", fake_saved_irregular)
app = FastAPI() app = FastAPI()
app.include_router(router_module.router) app.include_router(router_module.router)
return TestClient(app) return TestClient(app)
@@ -298,7 +304,11 @@ def test_migrate_keeps_existing_structures(client, tmp_path):
assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로) assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로)
def test_migrate_empty_list_is_noop(client): def test_migrate_empty_list_is_noop(client, monkeypatch):
async def none_saved(project_id, root, route_id):
return []
monkeypatch.setattr(router_module, "_saved_irregular_stations", none_saved)
response = client.post( response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []} f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
) )