- adjustStation — 누른 점이 꺾임이면 pivotAxes 로 좌우 다음 꺾임(없으면 시점 · 종점)을 축으로 사이 측점을 새 직선 위로 · 사이 R 키 지움 · 직선 안쪽 · 처음 계획선은 옛 값 그대로 - 새 logic/B05_Profile_UI_Profile_Pivot.ts — 타입만 import(순환 없음) - 시험 test_355_1 · helper_355_1 세 경우 · test_213_1/2 tsc 산출 ESM import 에 .js 붙임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
219 lines
8.7 KiB
Python
219 lines
8.7 KiB
Python
"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3).
|
||
|
||
까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은
|
||
263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점
|
||
편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단).
|
||
고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의
|
||
꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도
|
||
옛 글자를 같은 측점으로 봄.
|
||
실자료 = 고정 사본(fixtures/b05_213_1_hidden_station_longitudinal.json ·
|
||
d2245e3b initial_snapshot 종단 160~360m · 213-1 이전 재현 상태 · 234-2)
|
||
— 사용자 storage 에 기대지 않음.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import re
|
||
import shutil
|
||
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 ( # noqa: E402
|
||
AlignmentPolicy,
|
||
build_alignment,
|
||
)
|
||
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
|
||
|
||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||
SOURCE = PROJECT_ROOT / "resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json"
|
||
REL = "B06_Section/longitudinal/longitudinal.json"
|
||
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="213-1 고정 사본 없음")
|
||
|
||
# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998)
|
||
STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998]
|
||
BASE = [
|
||
(0.0, 100.0),
|
||
(20.0, 99.0),
|
||
(40.0, 98.5),
|
||
(263.206, 90.0),
|
||
(280.0, 89.0),
|
||
(300.0, 88.0),
|
||
(1000.0, 60.0),
|
||
]
|
||
EDITS = {
|
||
"station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3},
|
||
"curve_radii": {"263.206": 300.0},
|
||
}
|
||
|
||
|
||
def _synthetic() -> dict:
|
||
chainage = np.arange(0.0, 1000.0, 1.0)
|
||
return {
|
||
"base_s": np.array([s for s, _ in BASE]),
|
||
"base_z": np.array([z for _, z in BASE]),
|
||
"chainage": np.r_[chainage, 1000.0],
|
||
"ground": np.r_[100.0 - chainage * 0.04, 60.0],
|
||
"stations": [
|
||
{"station_id": f"st{index}", "chainage_m": value}
|
||
for index, value in enumerate(STATIONS)
|
||
],
|
||
}
|
||
|
||
|
||
def _python(edits: dict) -> dict:
|
||
data = _synthetic()
|
||
return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits))
|
||
|
||
|
||
def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]:
|
||
pvi = [node["chainage_m"] for node in alignment["pvi"]]
|
||
return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit]
|
||
|
||
|
||
def test_rounded_station_keys_collapse_onto_station():
|
||
alignment = _python(EDITS)
|
||
pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]}
|
||
assert _close_pairs(alignment) == []
|
||
assert 263.206 not in pvi and 263.21 in pvi
|
||
# 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로
|
||
assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3}
|
||
assert alignment["edits"]["curve_radii"] == {"263.210": 300.0}
|
||
# 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음)
|
||
assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0
|
||
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
|
||
|
||
|
||
def test_snapped_base_keeps_station_elevation():
|
||
"""편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로."""
|
||
alignment = _python({"station_offsets": {}, "curve_radii": {}})
|
||
node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9)
|
||
expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE]))
|
||
assert node["elevation_m"] == pytest.approx(expected, abs=1e-6)
|
||
|
||
|
||
_RUNNER = """
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js";
|
||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||
const result = buildAlignment(input.base, input.edits);
|
||
writeFileSync(process.argv[3], JSON.stringify(result));
|
||
"""
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def ts_alignment(tmp_path_factory):
|
||
if not TSC.is_file():
|
||
pytest.skip("프론트엔드 의존성 미설치")
|
||
tmp = tmp_path_factory.mktemp("align213")
|
||
out = tmp / "js"
|
||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
[
|
||
"node",
|
||
str(TSC),
|
||
str(PROJECT_ROOT / "B05_Profile" / "logic" / "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")
|
||
for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요
|
||
text = js.read_text(encoding="utf-8")
|
||
js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8")
|
||
data = _synthetic()
|
||
base = {
|
||
"policy": AlignmentPolicy.from_dict({}).as_dict(),
|
||
"onlyExplicitCurves": False,
|
||
"basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE],
|
||
"chainage": data["chainage"].tolist(),
|
||
"ground": data["ground"].tolist(),
|
||
"stations": data["stations"],
|
||
}
|
||
src, dst = tmp / "in.json", tmp / "out.json"
|
||
src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8")
|
||
subprocess.run( # noqa: S603
|
||
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||
)
|
||
return json.loads(dst.read_text(encoding="utf-8"))
|
||
|
||
|
||
def test_mirror_python_ts(ts_alignment):
|
||
py = _python(EDITS)
|
||
ts = ts_alignment
|
||
assert ts["edits"] == py["edits"]
|
||
assert len(ts["base_pvi"]) == len(py["base_pvi"])
|
||
for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True):
|
||
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6)
|
||
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
|
||
assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx(
|
||
[n["chainage_m"] for n in py["pvi"]], abs=1e-6
|
||
)
|
||
assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx(
|
||
[n["elevation_m"] for n in py["pvi"]], abs=1e-6
|
||
)
|
||
for a, b in zip(ts["stations"], py["stations"], strict=True):
|
||
assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6)
|
||
|
||
|
||
def _copy(tmp_path: Path) -> Path:
|
||
target = tmp_path / REL
|
||
target.parent.mkdir(parents=True)
|
||
shutil.copyfile(SOURCE, target)
|
||
return target
|
||
|
||
|
||
@needs_copy
|
||
def test_d2245e3b_no13_3_spike_gone(tmp_path):
|
||
data = json.loads(_copy(tmp_path).read_text(encoding="utf-8"))
|
||
edits = data["profile_alignment"]["edits"]
|
||
assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건
|
||
alignment, _ = rebuild_alignment_profile(data, edits)
|
||
assert _close_pairs(alignment) == []
|
||
assert "263.206" not in alignment["edits"]["station_offsets"]
|
||
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
|
||
|
||
|
||
@needs_copy
|
||
def test_d2245e3b_station_regen_drops_rounded_key(tmp_path):
|
||
"""[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐."""
|
||
path = _copy(tmp_path)
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
irregular = []
|
||
for station in data["stations"]:
|
||
if station.get("kind") != "irregular":
|
||
continue
|
||
item = copy.deepcopy(station)
|
||
if abs(item["chainage_m"] - 263.21) < 1e-6:
|
||
item["chainage_m"] = 263.31
|
||
irregular.append(item)
|
||
_merge_irregular_into_longitudinal(tmp_path, REL, irregular)
|
||
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
|
||
leftovers = [
|
||
value
|
||
for value in [n["chainage_m"] for n in saved["base_pvi"]]
|
||
+ [float(key) for key in saved["edits"]["station_offsets"]]
|
||
if 263.2 < value < 263.22
|
||
]
|
||
assert leftovers == []
|
||
assert _close_pairs(saved, 0.05) == []
|