"""예정노선 가이드 추천선 셈(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