Files
Aislo/resources/tester/test_route_polyline_handle_drag.py
eomsangdonandClaude Opus 5 0ef32b5279 chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt`
가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는
시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음.

- `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침**
- `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시)
- 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q`
  옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0

⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:12:30 +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" / "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