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

This commit is contained in:
2026-10-18 21:43:31 +09:00
12 changed files with 435 additions and 40 deletions
@@ -126,17 +126,15 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
try {
renderSections(detail, routeId);
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
const legacy = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). 측점은 서버가 저장
// 종단 파일에서 읽음 — 이 상세는 초안이 섞일 수 있어 보내지 않음(452).
const hasLegacy = detail.longitudinal.stations.some(
(station) => station.kind === "irregular",
);
const migrateKey = `${projectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) {
if (hasLegacy && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(projectId, legacy)
void migrateLegacyStations(projectId, routeId)
.then((result) => {
if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
+4 -3
View File
@@ -313,14 +313,15 @@ export async function saveStructures(
}
/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다.
* 측점은 서버가 저장 종단 파일에서 읽음 — 화면 상세(초안 섞임)를 안 보냄(452). */
export async function migrateLegacyStations(
projectId: string,
stations: Array<{ chainage_m: number; structure: string }>,
routeId: number,
): Promise<{ status: string; migrated: number; revision: number }> {
return requestJson(`/projects/${projectId}/route/structures/migrate`, {
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(
pipes: Sequence[PipePoint], structures: Sequence[Any]
) -> dict[float, tuple[str, str | None]]:
"""측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
owners: dict[float, tuple[str, str | None]] = {}
) -> dict[float, tuple[str, str | None, str]]:
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
(`plan_stations` 이름 규칙)."""
owners: dict[float, tuple[str, str | None, str]] = {}
for pipe in pipes:
for mark in plan_stations([pipe], []):
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 mark in plan_stations([], [item]):
owners.setdefault(
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
@@ -118,8 +124,9 @@ def station_keys(
) -> dict[str, float]:
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) ·
id 없는 관 · 구조물은 `at:` ·
시종점(맨 앞 · 맨 끝 = 관 · 구조물이 얹혀도 `end:BP/EP` · 화면 `buildStations` · B06
`stationKeysOf` 와 같음 · 452) > 관 · 구조물 자리(구간형 시작 · 끝 `:start` · `:end`) >
곡선 측점(`curve:` · 252-2) > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음).
"""
owners = _facility_owners(pipes, structures)
@@ -128,14 +135,14 @@ def station_keys(
out: dict[str, float] = {}
for index, chainage in enumerate(values):
owner = owners.get(round(chainage, 2))
if owner is not None:
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:
if index == 0:
key = station_key("bp", chainage, interval_m)
elif index == len(values) - 1:
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:
key = station_key("regular", chainage, interval_m)
else:
+40 -4
View File
@@ -9,6 +9,7 @@
"""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
@@ -16,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends
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 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):
"""구 비정규 측점 이관 요청 — 화면이 복원한 `{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)
@@ -267,7 +302,8 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
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세트가 최종본).
@@ -247,6 +247,7 @@ async def _regenerate_structure_stations(
_section_options_from_stored,
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)
@@ -269,6 +270,8 @@ async def _regenerate_structure_stations(
summary = _merge_irregular_into_longitudinal(
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
made, summary = await asyncio.to_thread(_regenerate)
+6 -3
View File
@@ -148,7 +148,8 @@ export function useStructureIds(
const looksLikePipe = (section: CrossSection): boolean =>
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(
projectId: string | null,
sections: ReadonlyArray<CrossSection>,
@@ -156,10 +157,12 @@ export function stationKeysOf(
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section) => {
const keys = sections.map((section, index) => {
const at = section.chainage_m;
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);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
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]));
/* 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 = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
@@ -156,9 +164,9 @@ const sections = [
{ chainage_m: 80.17, kind: "irregular" },
];
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);
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");
/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */
@@ -171,10 +179,10 @@ const staleSections = [
{ chainage_m: 80, 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();
m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음
out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key;
out.legacyKey = keysOf(E, [staleSections[1]])[0].key;
out.legacyBook = bookSize();
m.prunePipeIds([80], E); // 지금 관 자리만 남김
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 없는 관
"grid:2": 40.0,
"pipe:p-7": 50.0,
"structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐
"at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at:
"at:80.00": 80.0,
"structure:w1:start": 60.0, # 구간형 시작 · 끝 = :start · :end(452 · 화면과 한 규칙)
"structure:w1": 77.0,
"structure:w1:end": 80.0,
"end:EP": 95.5,
}
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, "_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, "_saved_irregular_stations", fake_saved_irregular)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
@@ -298,7 +304,11 @@ def test_migrate_keeps_existing_structures(client, tmp_path):
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(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
)