Files
Aislo/resources/tester/test_244_9_b08_milestone_route_map.py
T
eomsangdonandClaude Opus 5.5 2584325b16 feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)
- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px
- 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320)
- 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함
- 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:07:14 +09:00

239 lines
10 KiB
Python

"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로(사용자 138번 · [지도 캡처]는 244-11 에서 없앰).
서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 ·
화면 = 표지(거래처 · 기준점 · 시점) · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 ·
표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림).
"""
import json
import re
import subprocess
import sys
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone"
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts"
GEOCHANG = {"lon": 127.9095, "lat": 35.6866}
BYEONGGOK = {"lon": 127.85, "lat": 35.66}
BODY = {
"routes": [
{
"result_code": 0,
"summary": {"distance": 10463},
"sections": [
{"roads": [{"vertexes": [127.9095, 35.6866, 127.89, 35.68]}]},
{"roads": [{"vertexes": [127.87, 35.672, 127.85, 35.66]}]},
],
}
]
}
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(rels, script):
src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels)
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"], input=src + "\n" + script,
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _client(monkeypatch, body=BODY):
monkeypatch.setenv(ms.KAKAO_KEY, "fake-rest-key")
monkeypatch.setattr(ms, "_kakao_open", lambda r: json.dumps(body).encode())
app = FastAPI()
app.include_router(ms.router)
return TestClient(app)
def test_길찾기_경로_좌표_위도_경도(monkeypatch):
res = _client(monkeypatch).post(
BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK}
)
assert res.json() == {
"km": 10.5,
"reason": None,
"path": [[35.6866, 127.9095], [35.68, 127.89], [35.672, 127.87], [35.66, 127.85]],
}
assert "vertexes" not in res.text and "sections" not in res.text # 원문 안 돌려줌
def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch):
monkeypatch.delenv(ms.KAKAO_KEY, raising=False)
got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) # 키 없음 = 길 선 없음
assert got["km"] is None and "path" not in got
broken = json.loads(json.dumps(BODY))
broken["routes"][0]["sections"][1]["roads"][0]["vertexes"] = ["x", None]
body = (
_client(monkeypatch, broken)
.post(BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK})
.json()
)
assert body["km"] == 10.5 and body["path"] == []
def test_좌표_저장_안_함(tmp_path):
route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]}
route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]}))
files = [p for p in tmp_path.rglob("*") if p.is_file()]
assert [p.name for p in files] == ["milestone.json"]
text = files[0].read_text(encoding="utf-8")
assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text
def test_JS_키_길(monkeypatch):
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
monkeypatch.delenv(ms.KAKAO_JS, raising=False)
assert client.get(BASE + "/map-key").json() == {"key": None}
monkeypatch.setenv(ms.KAKAO_JS, " ")
assert client.get(BASE + "/map-key").json() == {"key": None}
monkeypatch.setenv(ms.KAKAO_JS, "fake-js-key")
assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"}
def test_표지_셋():
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []},
"routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True},
{"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}],
} # fmt: skip
d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
got = _node(
[LAYOUT],
f"""const D = {d};
console.log(JSON.stringify([
mapMarks(D, "r1", [127.83, 35.64]),
mapMarks(D, "r2", [127.83, 35.64]),
mapMarks({{ ...D, bases: [] }}, "r1", null),
]));""",
)
assert got[0] == [
{"name": "거창군청", "lon": 127.9, "lat": 35.68},
{"name": "병곡리", "lon": 127.85, "lat": 35.66},
{"name": "시점", "lon": 127.83, "lat": 35.64},
]
assert got[1] == [{"name": "시점", "lon": 127.83, "lat": 35.64}] # 좌표 없는 거래처 = 표지 없음
assert got[2] == [{"name": "거창군청", "lon": 127.9, "lat": 35.68}]
STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const injected: string[] = [];
(globalThis as any).document = { createElement: () => ({}),
head: { append: (s: any) => injected.push(s.src) } };
(globalThis as any).window = globalThis;
const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85,
lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] });
const siteInfo = () => ({ start_lonlat: [127.83, 35.64] });
const routePaths = () => PATHS;
"""
def _map(key, paths, extra=""):
script = f"""
const KEY = {json.dumps(key)};
const PATHS = {json.dumps(paths)};
const asked: string[] = [];
const request = async (url: string) => (asked.push(url), {{ key: KEY }});
{extra}
// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap)
const view = editMap("p1", {{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }});
view.set([{{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }},
{{ key: "base", name: "병곡리", lon: 127.85, lat: 35.66, movable: true }},
{{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }}], PATHS);
await new Promise((r) => setTimeout(r, 30));
const [hint, box] = view.root.children;
console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden,
log: (globalThis as any).LOG ?? null }}));
"""
return _node([LAYOUT], STUBS + IMPORTS.sub("", _read(KMAP)) + script)
def test_키_없음_안내_한_줄():
got = _map(None, [])
assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"]
assert got["injected"] == [] # SDK 안 부름
assert got["hint"] == (
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"
)
assert got["boxHidden"]
FAKE_KAKAO = """
const LOG: any = { overlays: [], bounds: [] };
(globalThis as any).LOG = LOG;
class LatLng { lat: number; lng: number;
constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } }
const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.push([kind, o]) });
(globalThis as any).kakao = { maps: {
load: (f: () => void) => f(),
LatLng,
LatLngBounds: class { extend(p: any) { LOG.bounds.push([p.lat, p.lng]); } },
Map: class { constructor() { LOG.maps = (LOG.maps ?? 0) + 1; }
relayout() {} setBounds() { LOG.fit = true; } },
Polyline: function (o: any) { return on("line", o); },
Marker: function (o: any) { return on("marker", o); },
CustomOverlay: function (o: any) { return on("label", o); },
event: { addListener: () => {} },
} };
"""
def test_키_있음_길_선_표지_전체_맞춤():
paths = [[[35.68, 127.9], [35.67, 127.87], [35.66, 127.85]], [[35.66, 127.85], [35.64, 127.83]]]
got = _map("fake-js-key", paths, FAKE_KAKAO)
log = got["log"]
assert not got["boxHidden"] and log["maps"] == 1 and log["fit"]
lines = [o for kind, o in log["overlays"] if kind == "line"]
assert [[[p["lat"], p["lng"]] for p in o["path"]] for o in lines] == paths
assert lines[0]["strokeColor"] == "#E60046" and lines[0]["strokeWeight"] >= 5
titles = [o["title"] for kind, o in log["overlays"] if kind == "marker"]
assert titles == ["거창군청", "병곡리", "시점"]
assert len(log["bounds"]) == 3 + 5 # 표지 셋 + 길 선 점 다섯 = 경로 전체 맞춤
assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도
assert "mapHead, map.root" in leg and "map.set(pins()" in leg
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
# 156 경로 탭 = 그림만(새 창 길찾기 · 지도 칸 없앰 · 길찾기 · 지도 = 연결선 모달)
assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route
assert "routeMapView" not in kmap
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만
text = rel.read_text(encoding="utf-8")
assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel
for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT,
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700