# -*- coding: utf-8 -*- """344-2 실시간 바탕 짝 — 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 노선 바뀜 때 계획고 옮김. 거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값): ① 첫 계획선 base_pvi — `design_ground_following_profile` ↔ `logic/B05_Profile_Grade_Ground.ts` (1순위만 · 못 서는 땅이면 TS 는 null = 「서버로 다시 셈」). ② 관 자리 투영 — `common_util_drainage_pipes.project_pipe_points` ↔ `common_util_route_follow.ts` `projectPipePoints`. ③ 구조물 따라감 — `common_util_route_follow.follow_structures` ↔ `followStructures`(20 m 상수 같음). ④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`. 서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감. """ from __future__ import annotations import json import math import subprocess import sys from pathlib import Path from types import SimpleNamespace import pytest 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 structures_router # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 carry_profile_edits, changed_span, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, ) from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 from common_util.common_util_route_follow import ( # noqa: E402 fill_structure_coordinates, follow_structures, ) from common_util.common_util_route_geometry import RouteVertex # noqa: E402 from config.config_system import STRUCTURE_ROUTE_DRIFT_MAX_M # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "typescript").is_dir(), reason="프론트엔드 의존성 미설치", ) # ── 입력 만들기 ─────────────────────────────────────────────────────────────── def _longitudinal(total: float, *, wave: float, holes: tuple[int, ...] = ()) -> dict: samples = [] for index in range(int(total) + 1): s = float(min(index, total)) z = 100.0 + 0.06 * s + 4.0 * math.sin(s / wave) + 1.5 * math.cos(s / 7.3) samples.append({"chainage_m": s, "elevation_m": z, "valid": index not in holes}) if samples[-1]["chainage_m"] < total: samples.append({"chainage_m": total, "elevation_m": 100.0 + 0.06 * total, "valid": True}) stations = [float(s) for s in range(0, int(total) + 1, 20)] + [47.3, 133.0, total] return {"samples": samples, "stations": [{"chainage_m": s} for s in sorted(set(stations))]} def _ground_case(total: float, wave: float, stored: dict | None = None, paved=False) -> dict: longitudinal = _longitudinal(total, wave=wave, holes=(3, 4, 90)) options = resolve_grade_options("trunk", terrain_type="normal", paved=paved, stored=stored) alignment, _ = design_ground_following_profile(longitudinal, options) return { "py": alignment["base_pvi"], "ts_input": { "samples": longitudinal["samples"], "stations": [row["chainage_m"] for row in longitudinal["stations"]], "policy": alignment["policy"], "startOffsetM": options.start_elevation_offset_m, "endOffsetM": options.end_elevation_offset_m, }, } GROUND_CASES = [ _ground_case(200.0, 11.0), _ground_case(279.8, 23.0, {"start_elevation_offset_m": 0.4, "end_elevation_offset_m": -0.3}), _ground_case(160.4, 5.0, paved=True), ] GROUND_FAIL = { "samples": [{"chainage_m": 0.0, "elevation_m": 100.0}, {"chainage_m": 50.0, "valid": False}], "stations": [0.0, 50.0], "policy": GROUND_CASES[0]["ts_input"]["policy"], } OLD_ROUTE = [(0.0, 0.0), (40.0, 5.0), (80.0, 0.0), (120.0, 10.0), (160.0, 5.0), (200.0, 0.0)] L_OLD = round(sum(math.dist(a, b) for a, b in zip(OLD_ROUTE, OLD_ROUTE[1:])), 3) def _carry_case(new_route: list[tuple[float, float]]) -> dict: total = sum(math.dist(a, b) for a, b in zip(new_route, new_route[1:])) longitudinal = _longitudinal(total, wave=13.0) options = resolve_grade_options("trunk", terrain_type="normal") alignment, _ = design_ground_following_profile(longitudinal, options) base_pvi = [ {"chainage_m": s, "elevation_m": 200.0 + s / 10.0} for s in (0.0, 20.0, 40.0, 60.0, 90.5, 120.0, 150.0, 180.0, L_OLD) ] edits = { "station_offsets": {"20.000": 0.5, "90.500": -0.3, "180.000": 0.2, f"{L_OLD:.3f}": 0.1}, "curve_radii": {"40.000": 300.0, "150.000": 250.0}, } return { "old_xy": [{"x": x, "y": y} for x, y in OLD_ROUTE], "new_xy": [{"x": x, "y": y} for x, y in new_route], "base_pvi": base_pvi, "edits": edits, "ground_pvi": alignment["base_pvi"], } CARRY_CASES = [ # 가운데 노드(80, 0) 옮김 — 앞(0 ~ 40) 그대로 · 뒤(120 ~) 밂 _carry_case( [(0.0, 0.0), (40.0, 5.0), (85.0, -12.0), (120.0, 10.0), (160.0, 5.0), (200.0, 0.0)] ), # 끝 노드 옮김(꼬리 없음) _carry_case([(0.0, 0.0), (40.0, 5.0), (80.0, 0.0), (120.0, 10.0), (160.0, 5.0), (215.0, 9.0)]), # 앞 노드 옮김(앞머리 없음) · 점 하나 더함 _carry_case( [ (-6.0, 4.0), (20.0, 6.0), (40.0, 5.0), (80.0, 0.0), (120.0, 10.0), (160.0, 5.0), (200.0, 0.0), ] ), # 같음 _carry_case(OLD_ROUTE), ] NEW_LINE = [(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (120.0, 60.0)] PIPES = [ {"chainage_m": 10.0, "x": 12.3, "y": 1.1, "start_m": 8.0, "end_m": 14.0}, {"chainage_m": 70.0, "x": 48.0, "y": 25.0}, {"chainage_m": 5.0}, # 좌표 없음 — 누가거리 그대로 {"chainage_m": 90.0, "x": 55.0, "y": -5.0, "start_m": 88.0}, # 모서리 둘 사이 {"chainage_m": 150.0, "x": 119.5, "y": 61.0, "end_m": 152.0}, # 끝 밖 ] STRUCTURES = [ {"chainage_m": 20.0, "x": 30.0, "y": 2.0}, { "chainage_m": 3.0, "start_m": 0.0, "end_m": 12.0, "x": 1.0, "y": 0.5, }, # 앞으로 밀면 0 에서 자름 {"chainage_m": 100.0, "x": 80.0, "y": 90.0}, # 노선에서 멀어짐(>20 m) {"chainage_m": 40.0}, # 좌표 없음 ] @pytest.fixture(scope="module") def ts(tmp_path_factory) -> dict: source = tmp_path_factory.mktemp("s344") / "in.json" source.write_text( json.dumps( { "ground": [case["ts_input"] for case in GROUND_CASES] + [GROUND_FAIL], "carry": CARRY_CASES, "pipes": [{"points": PIPES, "line": [{"x": x, "y": y} for x, y in NEW_LINE]}], "structures": [ {"items": STRUCTURES, "line": [{"x": x, "y": y} for x, y in NEW_LINE]} ], } ), encoding="utf-8", ) proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_344_2_route_pairs.cjs"), str(source)], capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), check=False, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def _close(a: float, b: float, tol: float = 1e-6) -> bool: return abs(float(a) - float(b)) <= tol # ── ① 첫 계획선 ────────────────────────────────────────────────────────────── @pytest.mark.parametrize("index", range(len(GROUND_CASES))) def test_첫_계획선_거울(ts, index): py, got = GROUND_CASES[index]["py"], ts["ground"][index] assert len(got) == len(py) for a, b in zip(py, got): assert _close(a["chainage_m"], b["chainage_m"]) and _close( a["elevation_m"], b["elevation_m"] ) def test_첫_계획선은_측점에서_지반고(ts): case = GROUND_CASES[0] assert len(case["py"]) >= 13 # 모든 측점이 꼭짓점(호 보정으로 꼭짓점 표고는 지반에서 뜸) def test_못_서는_땅은_서버로(ts): assert ts["ground"][-1] is None longitudinal = {"samples": GROUND_FAIL["samples"], "stations": []} with pytest.raises(ValueError): design_ground_following_profile(longitudinal, resolve_grade_options("trunk")) # ── ② 관 자리 투영 ──────────────────────────────────────────────────────────── def _vertices(line): out, walked = [], 0.0 for index, (x, y) in enumerate(line): if index: walked += math.dist(line[index - 1], (x, y)) out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked)) return out def test_관_자리_투영_거울(ts): points = [ PipePoint( chainage_m=p["chainage_m"], x=p.get("x"), y=p.get("y"), start_m=p.get("start_m"), end_m=p.get("end_m"), ) for p in PIPES ] py = project_pipe_points(points, _vertices(NEW_LINE)) got = ts["pipes"][0] assert [p.chainage_m for p in py] == [g["chainage_m"] for g in got] for p, g in zip(py, got): assert p.start_m == g.get("start_m") and p.end_m == g.get("end_m") assert (p.x, p.y) == (g.get("x"), g.get("y")) # (55, -5) 은 두 선분에서 같은 거리 — 앞 선분 끝(50) · 끝 밖은 연장 안 정수(170) assert [g["chainage_m"] for g in got] == [5.0, 12.0, 50.0, 75.0, 170.0] # ── ③ 구조물 따라감 ─────────────────────────────────────────────────────────── def test_구조물_따라감_거울(ts): items = [SimpleNamespace(**{"start_m": None, "end_m": None, **s}) for s in STRUCTURES] for item in items: item.x, item.y = getattr(item, "x", None), getattr(item, "y", None) py = follow_structures(items, NEW_LINE) got = ts["structures"][0] for item, (drift, far), entry in zip(items, py, got): moved = entry["item"] assert item.chainage_m == moved["chainage_m"] assert item.start_m == moved.get("start_m") and item.end_m == moved.get("end_m") assert (drift is None) == (entry["drift_m"] is None) if drift is not None: assert _close(drift, entry["drift_m"], 1e-9) assert far == entry["far"] assert [entry["far"] for entry in got] == [False, False, True, False] assert got[1]["item"]["start_m"] == 0.0 # 앞으로 밀려도 0 에서 자름 assert got[3]["item"]["chainage_m"] == 40.0 # 좌표 없는 옛 자료 그대로 def test_멀어짐_거리_상수_한_벌(ts): assert ts["driftMax"] == STRUCTURE_ROUTE_DRIFT_MAX_M == 20.0 def test_저장_때_좌표_채움_자리가_그대로면_옛_좌표(): line = [(0.0, 0.0), (100.0, 0.0)] kept = SimpleNamespace(structure_id="a", chainage_m=30.0, start_m=None, end_m=None, x=31, y=9) moved = SimpleNamespace(structure_id="b", chainage_m=50.0, start_m=None, end_m=None, x=1, y=1) fresh = SimpleNamespace( structure_id=None, chainage_m=70.0, start_m=None, end_m=None, x=None, y=None ) previous = { "a": SimpleNamespace(chainage_m=30.0, start_m=None, end_m=None, x=31, y=9), "b": SimpleNamespace(chainage_m=40.0, start_m=None, end_m=None, x=1, y=1), } fill_structure_coordinates([kept, moved, fresh], line, previous=previous) assert (kept.x, kept.y) == (31, 9) # 따라간 땅 자리(노선 밖) 그대로 assert (moved.x, moved.y) == (50.0, 0.0) # 사용자가 옮김 → 노선 위 새 자리 assert (fresh.x, fresh.y) == (70.0, 0.0) def test_읽을_때는_없는_것만(): line = [(0.0, 0.0), (100.0, 0.0)] old = SimpleNamespace(chainage_m=30.0, x=None, y=None) has = SimpleNamespace(chainage_m=60.0, x=5.0, y=5.0) assert fill_structure_coordinates([old, has], line, only_missing=True) == 1 assert (old.x, old.y) == (30.0, 0.0) and (has.x, has.y) == (5.0, 5.0) def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch): route = tmp_path / "route.geojson" route.write_text( json.dumps({"geometry": {"coordinates": [[0, 0, 0], [100, 0, 0]]}}), encoding="utf-8" ) items = [ SimpleNamespace(chainage_m=30.0, start_m=None, end_m=None, x=None, y=None), # 옛 자료 SimpleNamespace(chainage_m=60.0, start_m=55.0, end_m=65.0, x=60.0, y=0.0), ] saved: dict = {} monkeypatch.setattr(structures_router, "load_structures", lambda root: (4, items)) monkeypatch.setattr( structures_router, "save_structures", lambda root, values, *, base_revision: saved.update(values=values, revision=base_revision), ) # 새 노선 = 앞쪽 10 m 늘림(같은 땅 자리는 누가거리 +10) new_xy = [{"x": -10.0, "y": 0.0}, {"x": 100.0, "y": 0.0}] result = structures_router.follow_structures_to_route(tmp_path, "route.geojson", new_xy) assert result == {"moved": 2, "far": 0} assert saved["revision"] == 4 assert [(i.chainage_m, i.start_m, i.end_m) for i in items] == [ (40.0, None, None), (70.0, 65.0, 75.0), ] assert (items[0].x, items[0].y) == (30.0, 0.0) # 옛 노선으로 채운 땅 자리 # ── ④ 계획고 옮김 ───────────────────────────────────────────────────────────── def _py_carry(case): span = changed_span( [(p["x"], p["y"]) for p in case["old_xy"]], [(p["x"], p["y"]) for p in case["new_xy"]] ) result = ( carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"]) if span else None ) return span, result @pytest.mark.parametrize("index", range(len(CARRY_CASES))) def test_계획고_옮김_거울(ts, index): span, result = _py_carry(CARRY_CASES[index]) got = ts["carry"][index] if span is None: assert got["span"] is None and got["result"] is None return for key, value in span.items(): assert ( (value == got["span"][key]) if isinstance(value, bool) else _close(value, got["span"][key], 1e-9) ) base_pvi, edits = result ts_result = got["result"] assert len(base_pvi) == len(ts_result["basePvi"]) for a, b in zip(base_pvi, ts_result["basePvi"]): assert _close(a["chainage_m"], b["chainage_m"]) and _close( a["elevation_m"], b["elevation_m"] ) assert edits == ts_result["edits"] def test_가운데를_고치면_앞은_그대로_뒤는_밂(): span, (base_pvi, edits) = _py_carry(CARRY_CASES[0]) assert span["start_fixed"] and span["end_fixed"] delta = span["delta_m"] assert span["start_m"] == pytest.approx(math.dist(OLD_ROUTE[0], OLD_ROUTE[1])) # 앞 편집 그대로 · 안(90.5) 버림 · 뒤(180 · 207.6) 길이 차만큼 assert edits["station_offsets"]["20.000"] == 0.5 assert "90.500" not in edits["station_offsets"] assert edits["station_offsets"][f"{180.0 + delta:.3f}"] == 0.2 assert edits["curve_radii"] == {"40.000": 300.0, f"{150.0 + delta:.3f}": 250.0} old = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["base_pvi"]} got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi} assert got[20.0] == old[20.0] and got[round(180.0 + delta, 3)] == old[180.0] # 바뀐 구간 안은 새 지반 첫 계획선 꼭짓점 ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["ground_pvi"]} inside = [s for s in got if span["start_m"] + 1e-6 < s < span["new_end_m"] - 1e-6] assert inside and all(got[s] == ground[s] for s in inside) assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + delta, abs=1e-3) def test_끝을_고치면_끝_꼭짓점은_새_지반(): span, (base_pvi, edits) = _py_carry(CARRY_CASES[1]) assert not span["end_fixed"] assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림 assert edits["station_offsets"]["90.500"] == -0.3 # 앞(바뀐 구간 앞) 편집 그대로 ground_end = CARRY_CASES[1]["ground_pvi"][-1] assert base_pvi[-1] == { "chainage_m": round(ground_end["chainage_m"], 6), "elevation_m": ground_end["elevation_m"], }