Files
Aislo/resources/tester/test_636_guide_route.py
T
eomsangdonandClaude Opus 5.5 e2da944c78 feat(636-1): 예정노선 가이드 추천선 셈 — 2 m 격자 A*(요구 546)
- 새 A00_Common/map/logic/map_guide_route.ts — 지점 차례 구간마다 A* · 20 m 걸음 56 방향 · ±14 % 막음 · 급경사 35°+ · 기울기 · 꺾임 벌점 · 가중 어림 × 3 · 상자 두 점 + 여유(끝에 닿았을 때만 두 배 한 번) · 펼침 30 만 + 450 ms 상한 · 첫 · 끝 걸음 정확 · 1 m 격자 2 칸 솎음 · 못 닿음 = 가장 가까이 간 데까지
- 화면 가이드 전용 · 파이썬 짝 없음 · 저장 없음
- 지킴 시험 test_636_guide_route — 평지 · 지그재그 · 못 닿음 · 지점 셋 · 급경사 벌점 · 1 km · 솎음 · 지형 밖

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:32:40 +09:00

188 lines
6.5 KiB
Python

"""예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — `A00_Common/map/logic/map_guide_route.ts`.
2 m 격자 A*(20 m 걸음 · ±14 % 막음 · 급경사 벌점 · 꺾임 벌점 · 펼침 상한).
평지 · 높이 차 지그재그(모든 걸음 ≤ 상한 · 첫 · 끝 걸음 포함) · 못 닿음 ≤ 0.5 s · 지점 셋 ·
급경사 벌점 효과 · 1 km ≤ 0.3 s · 1 m 격자 솎음 · 지형 밖.
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
./venv/Scripts/python.exe -m pytest resources/tester/test_636_guide_route.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_guide_route.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GMAX = 0.14
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { guideRoute } from "./A00_Common/map/logic/map_guide_route.js";
// 산 꼴 — 급경사 35°+ 약 2 할 · 동쪽으로 12 % 오름
const hills = (x, y) =>
10 * Math.sin(x / 37) * Math.cos(y / 53) + 6.25 * Math.sin((x + 2 * y) / 19) + 0.12 * x;
const TERRAIN = {
flat: () => 100,
plane: (x) => 0.3 * x,
hills,
cliff: (x, y) => hills(x, y) + (x >= 0 ? 500 : 0),
hole: (x, y) => (Math.hypot(x, y) < 50 ? NaN : 0),
};
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {};
for (const [key, c] of Object.entries(cases)) {
const terrain = { heightAt: TERRAIN[c.terrain], cell: c.cell ?? 2 };
const runs = [];
for (let k = 0; k < (c.repeat ?? 1); k++)
runs.push(guideRoute(c.points, terrain, c.options ?? {}));
out[key] = { ...runs[runs.length - 1], allMs: runs.map((r) => r.ms) };
}
writeFileSync(process.argv[3], JSON.stringify(out));
"""
CASES = {
"flat": {"terrain": "flat", "points": [[0.7, 0.3], [500.4, 3.1]]},
"zigzag": {"terrain": "plane", "points": [[0.7, 0.3], [300.9, 1.3]]},
"cliff": {"terrain": "cliff", "points": [[-500.0, 0.0], [500.0, 0.0]]},
"three": {"terrain": "hills", "points": [[0.0, 0.0], [400.0, 300.0], [700.0, -100.0]]},
"steep0": {
"terrain": "hills",
"points": [[0.0, 0.0], [600.0, 400.0]],
"options": {"weights": {"steep": 0}},
},
"steep3": {"terrain": "hills", "points": [[0.0, 0.0], [600.0, 400.0]]},
"km": {"terrain": "hills", "points": [[0.0, 0.0], [800.0, 600.0]], "repeat": 3},
"lidar": {"terrain": "hills", "cell": 1, "points": [[0.0, 0.0], [300.0, 200.0]]},
"outside": {"terrain": "hole", "points": [[0.0, 0.0], [300.0, 0.0]]},
}
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("guide_route")
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, result = tmp / "job.json", 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 _grades(vertices: list) -> list[float]:
return [
abs(b[2] - a[2]) / math.hypot(b[0] - a[0], b[1] - a[1])
for a, b in zip(vertices, vertices[1:])
]
def _check_ends(seg: dict, start: list, goal: list) -> None:
assert seg["vertices"][0][:2] == start
assert seg["vertices"][-1][:2] == goal
def test_평지_곧게(r):
(seg,) = r["flat"]["segments"]
assert seg["reached"]
_check_ends(seg, *CASES["flat"]["points"])
span = math.dist(*CASES["flat"]["points"])
assert seg["length"] <= span * 1.1
assert seg["maxGrade"] == 0 and seg["steepRatio"] == 0
def test_높이_차_지그재그_모든_걸음_상한_안(r):
(seg,) = r["zigzag"]["segments"]
assert seg["reached"]
_check_ends(seg, *CASES["zigzag"]["points"])
grades = _grades(seg["vertices"])
assert max(grades) <= GMAX + 1e-9 # 첫 · 끝 걸음 포함
assert seg["maxGrade"] == pytest.approx(max(grades))
assert seg["length"] >= 0.3 * 300 / GMAX # 높이 차 90 m 를 14 % 로
steps = [math.dist(a[:2], b[:2]) for a, b in zip(seg["vertices"], seg["vertices"][1:])]
assert all(19 <= s <= 21.5 for s in steps[:-1]) # 꼭짓점 그대로(끝 걸음만 짧을 수 있음)
def test_못_닿음_가장_가까이_간_데까지_빠르게(r):
(seg,) = r["cliff"]["segments"]
assert not seg["reached"]
assert seg["vertices"][0][:2] == CASES["cliff"]["points"][0]
assert -40 <= seg["vertices"][-1][0] < 0 # 벼랑 앞까지
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert r["cliff"]["ms"] <= 500
def test_지점_셋_구간마다_이어짐(r):
points = CASES["three"]["points"]
segs = r["three"]["segments"]
assert len(segs) == 2
for seg, a, b in zip(segs, points, points[1:]):
assert seg["reached"]
_check_ends(seg, a, b)
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert segs[0]["vertices"][-1] == segs[1]["vertices"][0]
def test_급경사_벌점이_급경사_지남을_줄임(r):
off = r["steep0"]["segments"][0]
on = r["steep3"]["segments"][0]
assert off["reached"] and on["reached"]
assert on["steepRatio"] < off["steepRatio"]
def test_1_km_0_3_초_안(r):
(seg,) = r["km"]["segments"]
assert seg["reached"]
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert min(r["km"]["allMs"]) <= 300
def test_1_m_격자는_2_m_로_솎음(r):
(seg,) = r["lidar"]["segments"]
assert seg["reached"]
inner = seg["vertices"][1:-1]
assert inner and all(v[0] % 2 == 0 and v[1] % 2 == 0 for v in inner)
def test_지형_밖_지점은_못_닿음(r):
(seg,) = r["outside"]["segments"]
assert not seg["reached"]
assert len(seg["vertices"]) == 1