- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림) - 기준점 상자 주소 · 주소 줄바꿈(최대 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
239 lines
10 KiB
Python
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
|