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:
@@ -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
|
||||
Reference in New Issue
Block a user