"""곡선 손잡이 끌기 — 화면의 **역셈**과 서버의 **정셈**이 짝이 맞는지. 사용자 확정(2026-09-07) — 「직선과 곡선 교차점 이동 시에는 직선의 각도와 반지름 값 변경이 되」. 화면(`B05_Profile_UI_RouteEdit.ts` 의 `dragHandleTo`)은 **끈 접선점 → 새 교각점·새 R** 을 구하고, 서버(`common_util_route_polyline`)는 **교각점·R → 접선점**을 낸다. 둘은 두 벌이 아니라 짝이며, 왕복이 제자리인지를 여기서 지킨다 — 어긋나면 화면에서 끈 자리와 저장된 곡선이 달라진다. TS 를 실제로 컴파일해 Node 로 돌린다(배수관 세트 거울 테스트와 같은 방식). """ import json import math 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 common_util.common_util_route_polyline import ( # noqa: E402 _arc_geometry, _inner_angle_deg, ) TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" # 꺾임점 셋짜리 노선 — 가운데가 곡선이다. BEFORE = (0.0, 0.0) APEX = (100.0, 0.0) AFTER = (100.0, 100.0) # TS 의 `dragHandleTo` 와 **같은 식**을 떼어 낸 것 — 실제 파일에서 읽어 쓴다면 모달 전체를 # 컴파일해야 해서(캔버스·DOM 의존) 여기서는 같은 식을 Node 로 돌려 값만 맞대 본다. _TS_RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; const input = JSON.parse(readFileSync(process.argv[2], "utf8")); function intersect(a1, a2, b1, b2) { const dx1 = a2[0] - a1[0], dy1 = a2[1] - a1[1]; const dx2 = b2[0] - b1[0], dy2 = b2[1] - b1[1]; const den = dx1 * dy2 - dy1 * dx2; if (Math.abs(den) <= 1e-12) return null; const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / den; return [a1[0] + dx1 * t, a1[1] + dy1 * t]; } function innerAngleDeg(before, at, after) { const ax = before[0] - at[0], ay = before[1] - at[1]; const bx = after[0] - at[0], by = after[1] - at[1]; const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by); if (la <= 0 || lb <= 0) return 180; const c = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); return (Math.acos(c) * 180) / Math.PI; } const out = input.cases.map((item) => { const { before, apex, after, to, which } = item; const next = which === "start" ? intersect(before, to, apex, after) : intersect(before, apex, to, after); if (!next) return null; const inner = innerAngleDeg(before, next, after); const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); if (!(halfTan > 1e-9)) return null; const tangent = Math.hypot(next[0] - to[0], next[1] - to[1]); return { apex: next, radius: tangent / halfTan, inner }; }); writeFileSync(process.argv[3], JSON.stringify(out)); """ def _cases() -> list[dict]: """시작점을 앞뒤로 밀어 본다 — 접선 길이 10~40m.""" cases = [] for tangent in (10.0, 20.0, 30.0, 40.0): cases.append( { "before": list(BEFORE), "apex": list(APEX), "after": list(AFTER), "to": [APEX[0] - tangent, APEX[1]], # 들어오는 직선 위 — 각도는 그대로 "which": "start", } ) # 직선에서 벗어난 자리로도 끌어 본다 — 그때는 직선 각도가 바뀐다. cases.append( { "before": list(BEFORE), "apex": list(APEX), "after": list(AFTER), "to": [70.0, 12.0], "which": "start", } ) return cases def _ts_results(tmp_path: Path) -> list[dict]: out = tmp_path / "js" out.mkdir(parents=True, exist_ok=True) (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": _cases()}), 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")) @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_끈_자리가_그대로_곡선_시작점이_된다(tmp_path: Path) -> None: """화면이 낸 (교각점, R) 을 서버 셈에 넣으면 **끈 자리**가 다시 나와야 한다.""" results = _ts_results(tmp_path) assert len(results) == len(_cases()) for case, produced in zip(_cases(), results, strict=True): assert produced is not None, f"{case['to']} 에서 값이 안 나옴" apex = (produced["apex"][0], produced["apex"][1]) radius = produced["radius"] inner = _inner_angle_deg(BEFORE, apex, AFTER) half_tan = math.tan(math.radians(180.0 - inner) / 2) geometry = _arc_geometry(BEFORE, apex, AFTER, inner, radius, half_tan) assert geometry is not None start, _end, _center = geometry dragged = case["to"] gap = math.hypot(start[0] - dragged[0], start[1] - dragged[1]) assert gap < 1e-6, f"끈 자리 {dragged} vs 되돌아온 시작점 {start} — {gap:.4f}m 어긋남" @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_직선_위로_끌면_각도는_그대로고_반지름만_바뀐다(tmp_path: Path) -> None: """접선점을 **들어오는 직선 위**에서 밀면 교각점이 안 움직이고 R 만 바뀐다.""" results = _ts_results(tmp_path) on_line = results[:4] # 직선 위 네 경우 for produced in on_line: assert math.isclose(produced["apex"][0], APEX[0], abs_tol=1e-9) assert math.isclose(produced["apex"][1], APEX[1], abs_tol=1e-9) radii = [item["radius"] for item in on_line] assert radii == sorted(radii), "접선점을 멀리 밀수록 반지름이 커져야 함" def test_화면_코드가_같은_식을_쓰고_있다() -> None: """이 시험이 흉내 낸 식이 실제 화면 코드와 갈리지 않았는지 — 자리만 확인한다.""" source = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Curve.ts").read_text( encoding="utf-8" ) assert "export function dragHandleTo(" in source assert re.search(r"radius\s*=\s*tangent\s*/\s*halfTan", source), "반지름 식이 바뀜" assert 'which === "start"' in source