"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 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 # 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 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, }, }, { "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, }, }, { # 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토(2026-09-09 사용자 확정). # ⚠ 그 바깥 성토는 노선 몫이 아니라 사토장 몫이라 `fill_area_m2` 에서 빠진다. "name": "사토장이 선 측점", "samples": _ground(-0.5), "design_elevation_m": 99.8, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "spoil_fill": {"side": "left", "width_m": 4.0}, }, }, { "name": "사토장 — 기울기를 따로 준 측점", "samples": _ground(-0.5), "design_elevation_m": 99.8, "options": { "ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left", "spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.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", surface_drop_m: "surfaceDropM", plan_radius_m: "planRadiusM", curve_outer_side: "curveOuterSide", curve_widening_m: "curveWideningM", }; // 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다. const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" }; const results = input.cases.map((item) => { const options = { standard: input.standard }; for (const [key, value] of Object.entries(item.options)) { if (key === "spoil_fill") { const spoil = {}; for (const [k, v] of Object.entries(value)) spoil[spoilCamel[k]] = v; options.spoilFill = spoil; continue; } 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_사토장_갈래가_빈_값으로_통과하지_않는다() -> None: """사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다.""" spoil = [ result for case, result in zip(CASES, _python_results(), strict=True) if case["name"].startswith("사토장") ] assert spoil, "사토장 사례가 있어야 한다" for result in spoil: assert result["spoil_fill_area_m2"] > 0 assert result["spoil_fill_replaced_fill_m2"] > 0 assert len(result["spoil_fill_line"]) >= 3 # 기울기를 눕히고 넓히면 더 담긴다. assert spoil[1]["spoil_fill_area_m2"] > spoil[0]["spoil_fill_area_m2"] 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)