diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index f83236d94..c7a7208d2 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -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}건을 구조물 목록으로 옮겼습니다.`); diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 3a1deaa1c..2c7f147dd 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -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 }), }); } diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index 46533d795..e26c9918e 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -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: diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index d425b0240..3efa4ca10 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -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세트가 최종본). diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index dd0ebd5e2..1a039a781 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -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) diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 5556e71d8..46f82a157 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -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, @@ -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)) { diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs index c41f8f15d..bb30a556a 100644 --- a/resources/tester/helper_411_b_pipe_id_pairing.mjs +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -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 }; diff --git a/resources/tester/helper_452_entry.ts b/resources/tester/helper_452_entry.ts new file mode 100644 index 000000000..ad4578246 --- /dev/null +++ b/resources/tester/helper_452_entry.ts @@ -0,0 +1,2 @@ +/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */ +export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys"; diff --git a/resources/tester/helper_452_keys.mjs b/resources/tester/helper_452_keys.mjs new file mode 100644 index 000000000..ff69c27c6 --- /dev/null +++ b/resources/tester/helper_452_keys.mjs @@ -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))); diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 4fdae39b3..5960dd7c2 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -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 # 파일 차례 열쇠 없음 diff --git a/resources/tester/test_452_server_keys_prune_migrate.py b/resources/tester/test_452_server_keys_prune_migrate.py new file mode 100644 index 000000000..5dfe73c67 --- /dev/null +++ b/resources/tester/test_452_server_keys_prune_migrate.py @@ -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 diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py index 74eb5d41c..40f5bbdce 100644 --- a/resources/tester/test_b05_structures_router.py +++ b/resources/tester/test_b05_structures_router.py @@ -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": []} )