"""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