"""소단 + 다중 무릎이 **끝나는지** — 화면이 멈추던 자리(2026-09-07 실사고). 무슨 일이 있었나 — 사용자 공용 브라우저에서 **소단을 한 건 놓자 화면이 25분 넘게 멈췄다** (렌더러가 1.4코어를 계속 태움). 서버(파이썬)로 같은 측점을 돌리면 0.01초에 끝나 서버는 멀쩡했다. 원인 — `cut_profile_points` 의 무릎 가지에서 **제자리 뒤집기**. 암 경계선의 기울기가 **암 경사와 토사 경사 사이**면 · 토사로 바꾸면 다음 걸음이 경계 **아래** → 「다시 암」 · 암으로 바꾸면 다음 걸음이 경계 **위** → 「다시 토사」 가 되고, 이때 보간 비율 `share` 가 0 이라 `knee_dist == dist` — **한 걸음도 안 나간다.** `while dist < limit` 이 끝나지 않는다. `multi_knee` 는 **소단이 있으면 항상 참**이라 소단이 곧 지뢰였다. 고침 — 무릎은 **앞으로 나아갈 때만** 인정한다(직전 무릎과 같은 자리면 뒤집지 않고 한 걸음 간다). 파이썬·TS 짝 둘 다 같은 모양으로 넣었다. """ import json import subprocess import sys import threading 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 common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402 TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" # 경계선 기울기를 두 설계 경사 **사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고 # 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m)면 어느 쪽으로 바꿔도 경계를 다시 넘는다. BOUNDARY_RISE = 1.5 CUT_RATIO, SOIL_CUT_RATIO = 0.4, 1.0 BERM = BermSpec(0.5, 3.0, 0.0) MAX_REACH_M = 50.0 def _boundary(dist: float) -> float: return 100.0 + dist * BOUNDARY_RISE def _run_with_timeout(seconds: float): """별도 스레드로 돌려 **끝나는지**를 본다 — 안 끝나면 그 자체가 결함이다.""" box: dict[str, object] = {} def work() -> None: box["points"] = cut_profile_points( 0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, BERM, MAX_REACH_M, True ) thread = threading.Thread(target=work, daemon=True) thread.start() thread.join(seconds) return None if thread.is_alive() else box.get("points") def test_경계_기울기가_두_경사_사이여도_끝난다(): points = _run_with_timeout(8.0) assert points is not None, "8초 안에 안 끝남 — 제자리 무릎이 되풀이되고 있다(화면이 멈춘다)" assert len(points) > 2 def test_꼭짓점이_뒤로_가지_않는다(): """무릎을 찍든 소단을 놓든 거리는 **늘 앞으로**만 간다.""" points = _run_with_timeout(8.0) assert points is not None for before, after in zip(points, points[1:], strict=False): assert after[0] >= before[0] - 1e-9, f"거리가 뒤로 감: {before} → {after}" def test_소단이_없으면_종전_그대로(): """이 고침이 소단 없는 경로(무릎 한 번)를 건드리지 않았는지.""" single = cut_profile_points( 0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, None, MAX_REACH_M, False ) assert single[0] == (0.0, 100.0) assert single[-1][0] > single[0][0] def test_두_파일이_같은_방식으로_막는다(): """짝이 갈리면 화면(TS)만 멈추고 서버(파이썬)는 멀쩡한 오늘 같은 일이 또 난다.""" py = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.py").read_text(encoding="utf-8") ts = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts").read_text(encoding="utf-8") assert "last_knee_dist" in py and "knee_dist > last_knee_dist" in py assert "lastKneeDist" in ts and "kneeDist > lastKneeDist" in ts @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_TS_짝도_끝난다(tmp_path: Path): """실제로 멈춘 것은 **화면(TS)** 이었다 — 그쪽도 끝나는지 직접 돌려 본다.""" out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts"), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) runner = out / "runner.mjs" runner.write_text( """ import { cutProfilePoints } from "./common_util_cross_berm.js"; const boundary = (d) => 100 + d * %s; const points = cutProfilePoints(0, 100, %s, %s, boundary, { widthM: 0.5, intervalM: 3, slopeDeg: 0 }, %s, true); console.log(JSON.stringify({ n: points.length, last: points[points.length - 1] })); """ % (BOUNDARY_RISE, CUT_RATIO, SOIL_CUT_RATIO, MAX_REACH_M), encoding="utf-8", ) done = subprocess.run( # noqa: S603 ["node", str(runner)], cwd=str(PROJECT_ROOT), capture_output=True, timeout=20, text=True ) assert done.returncode == 0, done.stderr[:400] ts = json.loads(done.stdout.strip().splitlines()[-1]) # 짝 대조 — 같은 기하에서 **꼭짓점 수와 끝점이 같아야** 한다. 한쪽만 고치면 여기서 걸린다. py_points = _run_with_timeout(8.0) assert py_points is not None assert ts["n"] == len(py_points), f"꼭짓점 수가 갈림 — TS {ts['n']} vs 파이썬 {len(py_points)}" assert abs(ts["last"][0] - py_points[-1][0]) < 1e-6 assert abs(ts["last"][1] - py_points[-1][1]) < 1e-6