"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울. 짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔ `common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음). """ import json import re import subprocess from pathlib import Path import pytest from B06_Section.B06_Section_Engine_Design import compute_cross_design from common_util.common_util_cross_berm import BermSpec, fill_profile_points from config.config_system import STANDARD_CROSS_SECTION ROOT = Path(__file__).resolve().parents[2] TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" def _ground(slope: float) -> list[dict]: return [ {"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True} for o in range(-40, 41) ] def _case(name, slope, z, mode, berm): return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm} # 좌절토 = 우측 성토(지반이 오른쪽으로 내려감) CASES = [ _case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)), _case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)), _case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)), _case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)), _case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)), _case("소단 없음", 0.6, 100.0, "left_cut", None), ] def _python(case): berm = BermSpec(*case["berm"]) if case["berm"] else None return compute_cross_design( case["samples"], case["z"], standard=STANDARD_CROSS_SECTION, ground_type="soil", section_mode=case["mode"], ditch_side="left", berm=berm, ) _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import { computeCrossDesign } from "./common_util_cross_design.js"; import { fillProfilePoints } from "./common_util_cross_berm.js"; const input = JSON.parse(readFileSync(process.argv[2], "utf8")); const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, { standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left", berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null, })); const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100); writeFileSync(process.argv[3], JSON.stringify({ designs, profile })); """ @pytest.fixture(scope="module") def ts_results(tmp_path_factory): if not TSC.is_file(): pytest.skip("프론트엔드 의존성 미설치") tmp = tmp_path_factory.mktemp("berm") out = tmp / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(ROOT), check=True, capture_output=True, ) for emitted in out.glob("*.js"): emitted.write_text( re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), encoding="utf-8", ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") src, dst = tmp / "in.json", tmp / "out.json" src.write_text( json.dumps({"cases": CASES, "standard": STANDARD_CROSS_SECTION}, ensure_ascii=False), 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 _flats(design: dict) -> list[tuple[float, float, float]]: """성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)].""" edge = design["road_edges"]["right"]["offset_m"] line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥 found: list[tuple[float, float, float]] = [] for a, b in zip(line, line[1:], strict=False): run = a["offset_m"] - b["offset_m"] if a["offset_m"] > edge + 1e-6 or run <= 1e-6: continue if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐 if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로 found[-1] = (found[-1][0], b["offset_m"], found[-1][2]) else: found.append((a["offset_m"], b["offset_m"], a["elevation_m"])) return found def _close(left, right, path=""): if isinstance(left, dict): assert set(left) == set(right), f"{path} 키 다름" for key in left: _close(left[key], right[key], f"{path}.{key}") elif isinstance(left, list): assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}" for i, (a, b) in enumerate(zip(left, right, strict=True)): _close(a, b, f"{path}[{i}]") elif isinstance(left, (int, float)) and not isinstance(left, bool): assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}" else: assert left == right, f"{path}: {left!r} vs {right!r}" def test_파이썬_TS_설계선_같음(ts_results): for case, ts in zip(CASES, ts_results["designs"], strict=True): _close(_python(case), ts, case["name"]) def test_사면_꼭짓점_거울(): py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0 assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평 assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지 def test_사면_꼭짓점_TS_같음(ts_results): py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) _close([list(p) for p in py], ts_results["profile"], "profile") def test_한_단_깊이_폭(): by = {case["name"]: _python(case) for case in CASES} edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"] flats = _flats(by["깊이 1.0 · 폭 0.5"]) assert len(flats) == 1 inner, outer, z = flats[0] assert z == pytest.approx(edge_z - 1.0, abs=1e-3) assert inner - outer == pytest.approx(0.5, abs=1e-3) assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0 zero = _flats(by["깊이 0 = 노면 끝 높이"]) assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3) assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"] assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0 assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == [] assert _flats(by["소단 없음"]) == [] # 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남 assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"] def test_절토_소단_끔(): """좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음.""" with_berm = _python(CASES[0]) plain = _python(CASES[-1]) left = [p for p in with_berm["design_line"] if p["offset_m"] > 0] assert left == [p for p in plain["design_line"] if p["offset_m"] > 0] assert with_berm["cut_area_m2"] == plain["cut_area_m2"] def test_저장분_깊이_읽기(): """[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0.""" from B06_Section.B06_Section_Router_Design import stored_berm assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0 assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0 assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5 def test_좌측_패널_칸_구간_깊이_나름(): """옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴). 좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`).""" source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") assert "depthM: spec.depth_m" in refresh