feat(b08): 거리이정표 경로 지도 = 카카오 지도 JS 위 길 선 · 지도 캡처 (244-9)

- 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(vertexes = 경도 · 위도 번갈아) · 저장 안 함 · km 만 저장 그대로
- 새 길 GET …/quantity/milestone/map-key — 카카오 지도 JavaScript 키(.env · 공개용) · 없으면 null
- 거래처 모달 · 경로 하위 탭에 카카오 지도 — 길 선 · 거래처 · 기준점 · 시점 표지 · 경로 전체 맞춤
- [지도 캡처] = 지금 탭 캡처 → 지도 칸만 자름(CropTarget · 없으면 자리 수치) → 그 경로 그림 · 트랙 바로 멈춤
- JS 키 없음 · SDK 못 받음 = 안내 한 줄 · 사진 올리기 · 다음 지도 새 창 길은 그대로
- 새 시험 test_244_9 · test_244_7 응답 꼴 고침(path 더함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 22:34:31 +09:00
co-authored by Claude Opus 5.5
parent 2a969a4f1d
commit 0816595e2e
9 changed files with 579 additions and 15 deletions
@@ -79,13 +79,14 @@ def test_가짜_응답_km_소수_한자리(monkeypatch):
monkeypatch.setattr(ms, "_kakao_open", fake)
res = _post(_client())
assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None}
assert res.status_code == 200
assert res.json() == {"km": 10.5, "reason": None, "path": [[35.6, 127.9]]} # 244-9
# 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK
assert seen["url"].startswith(ms.KAKAO_URL + "?")
assert "origin=127.9095%2C35.6866" in seen["url"]
assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"]
assert seen["auth"] == f"KakaoAK {FAKE_KEY}"
# 경로 좌표 · 응답 원문은 안 돌려줌(km 만)
# 응답 원문은 안 돌려줌(km · 경로 좌표 [위도, 경도] 만 · 244-9)
assert "vertexes" not in res.text and "sections" not in res.text
# 끝수 = 소수 1자리 반올림
for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)):
@@ -0,0 +1,240 @@
"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번).
서버 = 길찾기 응답에 경로 좌표 [위도, 경도](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
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)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", 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(data)
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, base: {{ ...D.base, enabled: false }} }}, "r1", null),
cropBox({{ left: 100, top: 50, width: 400, height: 300 }}, 1000, 2000, 1200),
cropBox({{ left: -20, top: 500, width: 1100, height: 300 }}, 1000, 1000, 600),
]));""",
)
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}]
assert got[3] == {"sx": 200, "sy": 100, "sw": 800, "sh": 600} # × 2(devicePixelRatio)
assert got[4] == {"sx": 0, "sy": 500, "sw": 1000, "sh": 100} # 영상 밖은 잘라 냄
STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const createButton = (o: any) => ({ ...el("button", { text: o.label }), onClick: o.onClick });
const showToast = () => {};
const uploadImage = async () => {};
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}
const view = routeMapView("p1", "r1");
await new Promise((r) => setTimeout(r, 30));
const [bar, hint, box] = view.root.children;
console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden,
captureHidden: bar.children[0].hidden, label: bar.children[0].textContent,
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"] and got["captureHidden"] and got["label"] == "지도 캡처"
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); },
} };
"""
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 not got["captureHidden"] 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 "[지도 캡처]" in got["hint"]
def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "preferCurrentTab: true" in kmap and "crop.fromElement(target)" in kmap
assert "cropTo(" in kmap and "stream.getTracks().forEach((t) => t.stop())" in kmap
assert "uploadImage(projectId, routeId, file)" in kmap
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "mapView.root" in modal and "mapView.draw();" in modal
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId, () => loadImage(true))" in route
assert "mapView.draw();" in route
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(routeId, lines);" 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