"""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) == []