Files
Aislo/resources/tester/test_route_polyline_handle_drag.py
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

154 lines
6.4 KiB
Python

"""곡선 손잡이 끌기 — 화면의 **역셈**과 서버의 **정셈**이 짝이 맞는지.
사용자 확정(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" / "logic" / "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