"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다. 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`) ↔ `common_util/common_util_cross_design.ts` (`computeCrossDesign`) (단면적은 그 안에서 `..._Engine_Areas.py` ↔ `..._cross_design_areas.ts` 를 탄다) **계산 자리 규칙**(CLAUDE.md 5장)이 「초기값은 서버, 조작 중은 브라우저, 코드는 한 벌」이라 이 짝은 반드시 같은 값을 내야 한다. 한쪽만 고치면 **화면에 보이는 수량과 저장되는 수량이 갈린다** — 그런데 이 짝에는 거울 테스트가 없었다(2026-09-07 신설). 표준단면 수치는 파이썬 config 를 그대로 TS 에 넘긴다 — 출처가 같아야 기본값 차이가 아니라 **산식 차이**만 잡힌다(배수관 세트 거울 테스트와 같은 방식). """ import json import re import subprocess import sys from pathlib import Path 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 B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" # 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해 # 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다. _TOLERANCE = 1e-9 def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dict]: """가로 -12m ~ +12m 를 0.5m 간격으로 훑은 지반선. `gap` 구간은 결측으로 둔다.""" samples = [] offset = -12.0 while offset <= 12.0001: invalid = gap is not None and gap[0] <= offset <= gap[1] samples.append( { "offset_m": round(offset, 3), "elevation_m": None if invalid else round(100.0 + slope * offset, 4), "valid": not invalid, } ) offset += 0.5 return samples def _dip_ground() -> list[dict]: """양측 절토 · 우측 노면 끝 너머(−4.5 m 밖)에서 원지반이 급히 내려가는 지반(294-3).""" samples = [] for index in range(49): offset = -12.0 + index * 0.5 z = 101.05 + 0.2 * offset if offset >= -4.5 else 100.15 + 1.0 * (offset + 4.5) samples.append({"offset_m": offset, "elevation_m": round(z, 4), "valid": True}) return samples def _ridge_ground() -> list[dict]: """우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1).""" return [ {"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True} for o in (-12.0 + i * 0.5 for i in range(49)) ] def _rise_ground() -> list[dict]: """좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1).""" return [ {"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True} for o in (-12.0 + i * 0.5 for i in range(49)) ] # 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교 # 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간. CASES = [ { "name": "토사 좌절토 일반측구", "samples": _ground(0.35), "design_elevation_m": 100.4, "options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}, }, { "name": "리핑암 우절토 L형측구 2단경사", "samples": _ground(-0.42), "design_elevation_m": 99.6, "options": { "ground_type": "ripping_rock", "section_mode": "right_cut", "ditch_side": "right", "ditch_type": "l_type", "rock_boundary_offset_m": -0.8, "two_stage_slope": True, }, }, { "name": "발파암 2단경사 해제", "samples": _ground(0.55), "design_elevation_m": 101.2, "options": { "ground_type": "blasting_rock", "section_mode": "left_cut", "ditch_side": "left", "rock_boundary_offset_m": -1.4, "two_stage_slope": False, }, }, { # 316-1 — 복합경사 값 없음 = 기본 꺼짐(두 짝 같은 기본) "name": "발파암 복합경사 값 없음", "samples": _ground(-0.55), "design_elevation_m": 99.0, "options": { "ground_type": "blasting_rock", "section_mode": "left_cut", "ditch_side": "left", "rock_boundary_offset_m": -1.4, }, }, { "name": "포장 + 측구 없음", "samples": _ground(0.12), "design_elevation_m": 100.05, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "paved": True, "ditch_enabled": False, }, }, { "name": "세월교 노면 하강", "samples": _ground(0.2), "design_elevation_m": 100.6, "options": { "ground_type": "soil", "section_mode": "right_cut", "ditch_side": "right", "surface_drop_m": 0.45, }, }, { "name": "곡선부 확폭 — 바깥쪽 좌", "samples": _ground(0.28), "design_elevation_m": 100.3, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "plan_radius_m": 18.0, "curve_outer_side": "left", }, }, { "name": "곡선부 확폭 — 저장된 확폭량 우", "samples": _ground(-0.3), "design_elevation_m": 100.9, "options": { "ground_type": "soil", "section_mode": "right_cut", "ditch_side": "right", "plan_radius_m": 25.0, "curve_outer_side": "right", "curve_widening_m": 0.62, }, }, { "name": "지반선 결측 구간(사면이 안 닫힐 수 있음)", "samples": _ground(0.9, gap=(6.0, 12.0)), "design_elevation_m": 100.0, "options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}, }, { # 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 1:0.4 를 1:1.0 으로 눕힘. # 무릎 아래(암)만 바뀌고 위(토사)는 표준 그대로라, 두 쪽이 같은 지점에서 꺾여야 한다. "name": "암 절토 경사를 측점에서 바꿈", "samples": _ground(-0.5), "design_elevation_m": 99.8, "options": { "ground_type": "ripping_rock", "section_mode": "left_cut", "ditch_side": "left", "rock_boundary_offset_m": -1.0, "two_stage_slope": True, "cut_slope_ratio": 1.0, }, }, { # 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘 "name": "노견 확장 좌 0.7 · 우 1.2", "samples": _ground(0.35), "design_elevation_m": 100.4, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "shoulder_extend_left_m": 0.7, "shoulder_extend_right_m": 1.2, }, }, { # 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1) "name": "노견 확장 — 판정은 옮긴 노견", "samples": _ground(-0.05), "design_elevation_m": 100.0, "options": { "ground_type": "soil", "section_mode": "right_cut", "ditch_side": "right", "shoulder_extend_right_m": 3.0, "shoulder_extend_left_m": 2.5, }, }, { # 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님) "name": "마루 너머 = 성토 1:n", "samples": _ridge_ground(), "design_elevation_m": 100.0, "options": { "ground_type": "soil", "section_mode": "right_cut", "ditch_side": "right", "shoulder_extend_right_m": 3.0, }, }, { # 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름) "name": "성 → 절 넘어감", "samples": _rise_ground(), "design_elevation_m": 100.0, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "shoulder_extend_left_m": 2.5, }, }, { # 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임) "name": "확폭 좌 + 사면 이동 좌 0.4", "samples": _ground(0.28), "design_elevation_m": 100.3, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "plan_radius_m": 18.0, "curve_outer_side": "left", "shoulder_extend_left_m": 0.4, }, }, { # 286-2 — 물넘이포장 자리 = 측구 없음(좌절토라 자동이면 측구가 섬) "name": "물넘이포장 자리 측구 없음", "samples": _ground(0.35), "design_elevation_m": 100.4, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "paved": True, "tilted": True, }, }, { # 287-4 — 물넘이포장 자리 노면 = 경사각도로 지대 낮은 쪽 한 경사(지반이 좌로 오름 = 우가 낮음) "name": "물넘이포장 자리 노면 기울임", "samples": _ground(0.35), "design_elevation_m": 100.4, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "paved": True, "tilted": True, "tilt_pct": 5.2408, }, }, { # 294-3 — 양측 절토 · 낮은 쪽(우) 노면을 늘여 원지반이 내려와 만나는 자리까지 "name": "물넘이포장 낮은 쪽 노면 연장", "samples": _dip_ground(), "design_elevation_m": 100.4, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "paved": True, "tilted": True, "tilt_pct": 5.0, }, }, ] _TS_RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import { computeCrossDesign } from "./common_util_cross_design.js"; const input = JSON.parse(readFileSync(process.argv[2], "utf8")); const camel = { ground_type: "groundType", section_mode: "sectionMode", ditch_side: "ditchSide", ditch_type: "ditchType", paved: "paved", rock_boundary_offset_m: "rockBoundaryOffsetM", two_stage_slope: "twoStageSlope", cut_slope_ratio: "cutSlopeRatio", ditch_enabled: "ditchEnabled", ditch_choice: "ditchChoice", surface_drop_m: "surfaceDropM", plan_radius_m: "planRadiusM", curve_outer_side: "curveOuterSide", curve_widening_m: "curveWideningM", shoulder_extend_left_m: "shoulderExtendLeftM", shoulder_extend_right_m: "shoulderExtendRightM", tilted: "tilted", tilt_pct: "tiltPct", }; const results = input.cases.map((item) => { const options = { standard: input.standard }; for (const [key, value] of Object.entries(item.options)) { options[camel[key]] = value; } return computeCrossDesign(item.samples, item.design_elevation_m, options); }); writeFileSync(process.argv[3], JSON.stringify(results)); """ def _python_results() -> list[dict]: results = [] for case in CASES: results.append( compute_cross_design( case["samples"], case["design_elevation_m"], standard=STANDARD_CROSS_SECTION, **case["options"], ) ) return results def _ts_results(tmp_path: Path) -> list[dict]: """TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다.""" out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(PROJECT_ROOT / "common_util" / "common_util_cross_design.ts"), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) # tsc 가 낸 상대 import 에는 확장자가 없어 Node ESM 이 못 읽는다(번들러가 붙여 주던 몫). # 짝 파일이 서로를 부르므로 여기서 `.js` 를 채워 준다. 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(_TS_RUNNER, encoding="utf-8") payload = tmp_path / "input.json" result = tmp_path / "output.json" payload.write_text( json.dumps( { "cases": [ { "samples": case["samples"], "design_elevation_m": case["design_elevation_m"], "options": case["options"], } for case in CASES ], "standard": STANDARD_CROSS_SECTION, }, ensure_ascii=False, ), encoding="utf-8", ) subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(payload), str(result)], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) def _diff(left, right, path: str = "") -> list[str]: """두 값이 갈린 자리를 경로와 함께 모은다 — 어느 항목이 틀렸는지 바로 보이게.""" if isinstance(left, dict) and isinstance(right, dict): problems = [] for key in sorted(set(left) | set(right)): if key not in left: problems.append(f"{path}.{key}: 파이썬에 없음(TS={right[key]!r})") elif key not in right: problems.append(f"{path}.{key}: TS 에 없음(파이썬={left[key]!r})") else: problems.extend(_diff(left[key], right[key], f"{path}.{key}")) return problems if isinstance(left, list) and isinstance(right, list): if len(left) != len(right): return [f"{path}: 길이 {len(left)} vs {len(right)}"] problems = [] for index, (one, other) in enumerate(zip(left, right, strict=True)): problems.extend(_diff(one, other, f"{path}[{index}]")) return problems if isinstance(left, bool) or isinstance(right, bool): return [] if left == right else [f"{path}: {left!r} vs {right!r}"] if isinstance(left, (int, float)) and isinstance(right, (int, float)): if abs(float(left) - float(right)) <= _TOLERANCE: return [] return [f"{path}: {left!r} vs {right!r}"] return [] if left == right else [f"{path}: {left!r} vs {right!r}"] @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) -> None: expected = _python_results() actual = _ts_results(tmp_path) assert len(actual) == len(expected) problems: list[str] = [] for case, left, right in zip(CASES, expected, actual, strict=True): problems.extend(_diff(left, right, case["name"])) assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40]) def test_사토장_단면_칸_없음(tmp_path: Path) -> None: """380-1 — 사토장은 횡단에 안 그림 · 사토 단면 셈 지움 — 두 쪽 결과 모두 `spoil_fill_*` 칸 없음.""" for result in [*_python_results(), *_ts_results(tmp_path)]: assert not [key for key in result if key.startswith("spoil_fill")] def test_시험이_빈_결과로_통과하지_않는다() -> None: """면적이 실제로 나오는 경우가 섞여 있어야 대조가 뜻을 가진다.""" results = _python_results() assert len(results) == len(CASES) assert sum(1 for item in results if item["cut_area_m2"] > 0) >= 4 assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1 assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results) assert any(item["cut_rock_area_m2"] > 0 for item in results) def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> None: """151-2 — 노견 폭 그대로 · 차도 끝이 이동량만큼 바깥 · 확폭 출력엔 이동 몫 안 섞임.""" by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True)) base = compute_cross_design( _ground(0.35), 100.4, standard=STANDARD_CROSS_SECTION, ground_type="soil", section_mode="left_cut", ditch_side="left", ) moved = by_name["노견 확장 좌 0.7 · 우 1.2"] for side, sign, extend in (("left", 1, 0.7), ("right", -1, 1.2)): road = moved["road_edges"][side]["offset_m"] lane = moved["carriageway_edges"][side]["offset_m"] base_shoulder = ( base["road_edges"][side]["offset_m"] - base["carriageway_edges"][side]["offset_m"] ) assert abs((road - lane) - base_shoulder) < 1e-9 # 노견 폭 고정 assert abs(lane - (base["carriageway_edges"][side]["offset_m"] + sign * extend)) < 1e-9 assert abs(moved["carriageway_width_m"] - (base["carriageway_width_m"] + 1.9)) < 1e-9 assert moved["widening_left_m"] == 0.0 and moved["widening_right_m"] == 0.0 curve = by_name["곡선부 확폭 — 바깥쪽 좌"] both = by_name["확폭 좌 + 사면 이동 좌 0.4"] assert both["widening_left_m"] == curve["widening_left_m"] > 0 assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9 def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float: """노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리).""" sign = 1.0 if side == "left" else -1.0 start_offset = result["road_edges"][side]["offset_m"] + sign * skip line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"]) index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6) start, nxt = line[index], line[index + 1] return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"]) def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None: """370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동.""" by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True)) soil = STANDARD_CROSS_SECTION["soil"] ridge = by_name["마루 너머 = 성토 1:n"] assert ridge["section_mode"] == "left_cut" # 우 = 성토 assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐 assert ridge["fill_area_m2"] > 0 assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3 rise = by_name["성 → 절 넘어감"] assert rise["section_mode"] == "left_cut" # 좌 = 절토 assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left" assert ( abs( _outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"] ) < 1e-3 )