- logic/D03_PlanAnalysis_Draw.ts ↔ D03_PlanAnalysis_Draw.py 같은 이름 짝 · 높이는 인자 heightAt - 거울 시험 test_562_1_plan_draw.py — 두 쪽 값 대조 + 손셈 값 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""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
|
|
|
|
|
|
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"]),
|
|
("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,
|
|
};
|
|
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}
|
|
_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_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]
|