"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔ TS `logic/D03_PlanAnalysis_Draw.ts`. 같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값. ./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q """ from __future__ import annotations import json import math import re import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402 TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" def hill(x: float, y: float) -> float: if x > 800: return math.nan return 0.0005 * x * x + 3 * math.sin(y / 40) def plane(x: float, y: float) -> float: return 0.1 * x def bumps(x: float, y: float) -> float: return 5 * math.sin(x / 10) LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m # (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김 CASES = [ ("segmentLength", [[1, 2], [4, 6]]), ("groundSlopePct", [[0, 0], [100, 30], "hill"]), ("groundSlopePct", [[100, 30], [0, 0], "hill"]), ("groundSlopePct", [[700, 0], [900, 0], "hill"]), ("groundSlopePct", [[5, 5], [5, 5], "hill"]), ("fixedLengthPoint", [[10, 10], [13, 14], 20]), ("fixedLengthPoint", [[10, 10], [10, 10], 20]), ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]), ("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]), ("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]), ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]), ("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]), ("fixedSlopePoint", [[0, 0], [30, 0], 9, "bumps"]), ("fixedSlopePoint", [[0, 0], [60, 0], 9, "bumps"]), ("fixedSlopePoint", [[0, 0], [200, 0], 9, "bumps"]), ("compassCandidates", [[0, 0], 50, 5, "plane"]), ("compassCandidates", [[200, 10], 60, 15, "hill"]), ("compassCandidates", [[0, 0], 50, 50, "plane"]), ("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]), ("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]), ("lineLength", [LINE]), ("pointAtDistance", [LINE, -5]), ("pointAtDistance", [LINE, 75]), ("pointAtDistance", [LINE, 500]), ("distanceAlong", [LINE, [40, 70]]), ("distanceAlong", [LINE, [-10, -10]]), ("sliceLine", [LINE, 25, 160]), ("sliceLine", [LINE, 160, 25]), ("stationChainages", [210]), ("stationChainages", [40, 20]), ("stationChainages", [0]), ] _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; const heights = { hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)), plane: (x, y) => 0.1 * x, bumps: (x, y) => 5 * Math.sin(x / 10), }; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = cases.map(([name, args]) => draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))), ); writeFileSync(process.argv[3], JSON.stringify(out)); """ _HEIGHTS = {"hill": hill, "plane": plane, "bumps": bumps} _PY = { "segmentLength": draw.segment_length, "groundSlopePct": draw.ground_slope_pct, "fixedLengthPoint": draw.fixed_length_point, "fixedSlopePoint": draw.fixed_slope_point, "compassCandidates": draw.compass_candidates, "compassPoint": draw.compass_point, "lineLength": draw.line_length, "pointAtDistance": draw.point_at_distance, "distanceAlong": draw.distance_along, "sliceLine": draw.slice_line, "stationChainages": draw.station_chainages, } def _py(name: str, args: list) -> object: return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args)) def _close(a: object, b: object) -> bool: if a is None or b is None: return a is b if isinstance(a, (int, float)): return isinstance(b, (int, float)) and abs(a - b) < 1e-6 return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type] @pytest.fixture(scope="module") def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list: tmp = tmp_path_factory.mktemp("draw") out = tmp / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(ROOT), check=True, capture_output=True, ) for emitted in out.rglob("*.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(_RUNNER, encoding="utf-8") job = tmp / "job.json" result = tmp / "out.json" job.write_text(json.dumps(CASES), encoding="utf-8") subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(job), str(result)], cwd=str(ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) def test_ts_matches_python(ts_out: list) -> None: for (name, args), ts in zip(CASES, ts_out): assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args)) def test_hand_values() -> None: assert draw.segment_length((1, 2), (4, 6)) == 5 # 오르막 + · 내리막 − · 지형 밖 · 길이 0 up = draw.ground_slope_pct((0, 0), (100, 0), hill) assert up == pytest.approx(5.0) assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0) assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26)) # z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180 # 부호 무관 · 한도 100 m = 없음 assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6) assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6) assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None # 지형 밖에 닿음 assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None # 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300° got = draw.compass_candidates((0, 0), 50, 5, plane) angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got) assert angles == [60, 120, 240, 300] assert draw.compass_candidates((0, 0), 50, 50, plane) == [] near = draw.compass_point((0, 0), (10, 40), 50, 5, plane) assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6) assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None def test_fixed_slope_nearest_to_cursor() -> None: # 5·sin(x/10) 에서 9 % 자리가 여럿 — 마우스 가까운 자리(짧은 첫 자리 아님) def roots() -> list[float]: out, prev = [], None for i in range(1, 100001): t = i * 0.01 f = abs(bumps(t, 0)) - 0.09 * t if prev is not None and (prev < 0) != (f < 0): out.append(t) prev = f return out rs = roots() assert len(rs) >= 3 for cx in (30, 60, 200): want = min(rs, key=lambda r: abs(r - cx)) got = draw.fixed_slope_point((0, 0), (cx, 0), 9, bumps) assert got is not None and got[0] == pytest.approx(want, abs=0.02) first = draw.fixed_slope_point((0, 0), (30, 0), 9, bumps) far = draw.fixed_slope_point((0, 0), (200, 0), 9, bumps) assert first is not None and far is not None and far[0] > first[0] + 20 def test_line_values() -> None: assert draw.line_length(LINE) == 210 assert draw.point_at_distance(LINE, -5) == (0, 0) assert draw.point_at_distance(LINE, 75) == (30, 65) assert draw.point_at_distance(LINE, 500) == (90, 180) assert draw.distance_along(LINE, (40, 70)) == 80 assert draw.distance_along(LINE, (-10, -10)) == 0 assert draw.slice_line(LINE, 25, 160) == pytest.approx( [(15, 20), (30, 40), (30, 100), (60, 140)] ) assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160) assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210] assert draw.station_chainages(40, 20) == [0, 20, 40] assert draw.station_chainages(0) == [0]