Files
Aislo/resources/tester/test_244_9_b08_milestone_route_map.py
T
eomsangdonandClaude Sonnet 5.5 9172d730ff feat(B08 거리이정표): 연결 없이 소재지 넣기 · 구간 길 선 저장 · 시점 미연결 · 녹색 점 크게 (373)
경로 탭 지도 누름 · [소재지] = 거래처 등록 창 · 길 선 legs[0].path(소수 5자리 · 점 줄임 · 상한 500) 캐시 → [저장] ·
다시 이음/실패 = 옛 거리 · 선 지움 · 수기 = 선 지움 · 연결 점 r 9 · 불투명도 .85.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-17 12:06:35 +09:00

268 lines
12 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
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
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": null' 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 "putFound(projectId, from, res.km, path);" in state # 373 구간 자료(캐시 → [저장])
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
def test_373_구간_길_선_저장_소수_5자리_점_줄임(tmp_path):
line = [[35.6866123, 127.9095456], [35.686612, 127.909546], [35.66, 127.85]]
leg = {"road": None, "auto_km": 10.5, "path": line}
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [leg]}]}))
saved = ms.read_data(tmp_path)["routes"][0]["legs"][0]["path"]
assert [list(p) for p in saved] == [
[35.68661, 127.90955],
[35.68661, 127.90955],
[35.66, 127.85],
]
with pytest.raises(ValidationError):
ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [{"path": [[95.0, 127.0]]}]}]})
with pytest.raises(ValidationError): # 상한
ms.Milestone.model_validate(
{"routes": [{"id": "r1", "legs": [{"path": [[35.0, 127.0]] * (ms.PATH_MAX + 1)}]}]}
)
# 길찾기 응답 줄이기 — 같은 점 이어짐 뺌 · 상한 안 · 두 끝 유지
thin = ms.thin_path([[35.0, 127.0], [35.0, 127.0], [35.00001, 127.0]])
assert thin == [[35.0, 127.0], [35.00001, 127.0]]
wavy = [[35 + i * 1e-4, 127 + (i % 2) * 1e-5] for i in range(2000)]
small = ms.thin_path(wavy)
assert len(small) <= ms.PATH_MAX and small[0] == [35.0, 127.0] and small[-1][0] == wavy[-1][0]