- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림) - 기준점 상자 주소 · 주소 줄바꿈(최대 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
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""244-7 B08 거리이정표 — 카카오 길찾기 REST 로 km 자동.
|
|
|
|
서버 길 = 가짜 응답으로 km 셈(10463 m → 10.5 km) · 키 없음 안내 · 키 값이 응답 · 로그에 안 나옴 ·
|
|
구간 채움(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) · 사용자 km 우선 ·
|
|
도로 구분 안 고름 = pick(합에 안 듦) · 실제 카카오 한 번(AISLO_KAKAO_LIVE=1 일 때만).
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
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 ( # noqa: E402
|
|
compute_milestone,
|
|
migrate_milestone,
|
|
)
|
|
|
|
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
|
FAKE_KEY = "fake-kakao-key-0123456789"
|
|
GEOCHANG = {"lon": 127.9095, "lat": 35.6866} # 거창군청
|
|
BYEONGGOK = {"lon": 127.85, "lat": 35.66} # 병곡리
|
|
OK_BODY = {
|
|
"trans_id": "t",
|
|
"routes": [
|
|
{
|
|
"result_code": 0,
|
|
"result_msg": "길찾기 성공",
|
|
"summary": {"distance": 10463, "duration": 902},
|
|
"sections": [{"roads": [{"vertexes": [127.9, 35.6]}]}],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _node(src_rel, expr):
|
|
src = (ROOT / src_rel).read_text(encoding="utf-8")
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e",
|
|
IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"],
|
|
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():
|
|
app = FastAPI()
|
|
app.include_router(ms.router)
|
|
return TestClient(app)
|
|
|
|
|
|
def _post(client, origin=GEOCHANG, destination=BYEONGGOK):
|
|
return client.post(
|
|
"/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone/directions",
|
|
json={"origin": origin, "destination": destination},
|
|
)
|
|
|
|
|
|
def test_가짜_응답_km_소수_한자리(monkeypatch):
|
|
monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY)
|
|
seen = {}
|
|
|
|
def fake(request):
|
|
seen["url"] = request.full_url
|
|
seen["auth"] = request.get_header("Authorization")
|
|
return json.dumps(OK_BODY).encode()
|
|
|
|
monkeypatch.setattr(ms, "_kakao_open", fake)
|
|
res = _post(_client())
|
|
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 · 경로 좌표 [위도, 경도] 만 · 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)):
|
|
body = {"routes": [{"result_code": 0, "summary": {"distance": meters}}]}
|
|
monkeypatch.setattr(ms, "_kakao_open", lambda r, b=body: json.dumps(b).encode())
|
|
assert ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK))["km"] == km
|
|
|
|
|
|
def test_키_없음_안내(monkeypatch):
|
|
monkeypatch.delenv(ms.KAKAO_KEY, raising=False)
|
|
called = []
|
|
monkeypatch.setattr(ms, "_kakao_open", lambda r: called.append(r))
|
|
body = _post(_client()).json()
|
|
assert body["km"] is None and "키가 없습니다" in body["reason"] and "손으로" in body["reason"]
|
|
assert not called # 키 없으면 카카오를 부르지 않음
|
|
monkeypatch.setenv(ms.KAKAO_KEY, " ")
|
|
assert "키가 없습니다" in _post(_client()).json()["reason"]
|
|
|
|
|
|
def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog):
|
|
monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY)
|
|
caplog.set_level(logging.DEBUG)
|
|
client = _client()
|
|
|
|
def http(code):
|
|
def fake(request):
|
|
raise urllib.error.HTTPError(
|
|
request.full_url, code, f"bad {FAKE_KEY}", {}, io.BytesIO(FAKE_KEY.encode())
|
|
)
|
|
|
|
return fake
|
|
|
|
def boom(request):
|
|
raise OSError(f"끊김 Authorization: KakaoAK {FAKE_KEY}")
|
|
|
|
cases = [
|
|
(http(401), "키를 받지 않았습니다"),
|
|
(http(429), "요청 수를 넘었습니다"),
|
|
(http(500), "HTTP 500"),
|
|
(boom, "닿지 못했습니다"),
|
|
(lambda r: b"{not json " + FAKE_KEY.encode(), "닿지 못했습니다"),
|
|
(lambda r: json.dumps({"routes": [{"result_code": 104, "result_msg":
|
|
"출발지와 도착지가 5 m 이내"}]}).encode(), "5 m 이내"),
|
|
] # fmt: skip
|
|
for fake, want in cases:
|
|
monkeypatch.setattr(ms, "_kakao_open", fake)
|
|
res = _post(client)
|
|
assert res.status_code == 200 and res.json()["km"] is None
|
|
assert want in res.json()["reason"], res.json()
|
|
assert FAKE_KEY not in res.text
|
|
assert FAKE_KEY not in caplog.text
|
|
# 잘못된 좌표 = 422(카카오 안 부름)
|
|
assert _post(client, origin={"lon": 999, "lat": 0}).status_code == 422
|
|
assert FAKE_KEY not in ms.__doc__ and "KakaoAK" not in (ms.__doc__ or "")
|
|
|
|
|
|
def _data(base_enabled=True, base_ll=(127.85, 35.66)):
|
|
"""254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀."""
|
|
return migrate_milestone({
|
|
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
|
"base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None,
|
|
"lon": base_ll[0], "lat": base_ll[1], "legs": []},
|
|
"routes": [
|
|
{"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None,
|
|
"lon": 127.9095, "lat": 35.6866, "legs": [{"road": "2차포장", "km": 23.8}],
|
|
"rows": [{"item": "장비", "dest": "mid", "note": ""}]},
|
|
{"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None,
|
|
"lon": None, "lat": None, "legs": [{"road": None, "km": None}],
|
|
"rows": [{"item": "철근", "dest": "start", "note": ""}]},
|
|
],
|
|
}) # fmt: skip
|
|
|
|
|
|
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
|
|
START = [127.81, 35.75]
|
|
|
|
|
|
def test_구간_채움():
|
|
d = json.dumps(_data())
|
|
seg = _node(LAYOUT, f"kmSegments({d}, 'r1', {json.dumps(START)})")
|
|
assert [(s["key"], s["text"]) for s in seg] == [
|
|
("route", "거창군청 → 병곡리 968-31"),
|
|
("base", "병곡리 968-31 → 시점"),
|
|
]
|
|
assert seg[0]["from"] == [127.9095, 35.6866] and seg[0]["to"] == [127.85, 35.66]
|
|
assert seg[1]["from"] == [127.85, 35.66] and seg[1]["to"] == START
|
|
# 기준점 없음 = 거래처 → 시점 하나 · 기준점 안 거침 · 거래처 좌표 없음 = from null
|
|
off = json.dumps(_data(base_enabled=False))
|
|
one = _node(LAYOUT, f"kmSegments({off}, 'r1', {json.dumps(START)})")
|
|
assert [(s["key"], s["text"], s["to"]) for s in one] == [("route", "거창군청 → 시점", START)]
|
|
two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)")
|
|
assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}]
|
|
# 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로
|
|
put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)")
|
|
assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}]
|
|
assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}]
|
|
# 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값
|
|
lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)")
|
|
line = {ln["from"]: ln for ln in lay["lines"]}
|
|
# 254 선 글 두 줄(도로 구분 / km)
|
|
assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"]
|
|
assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""]
|
|
assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로
|
|
|
|
|
|
def test_사용자_값_우선_고르기_표시():
|
|
data = _data()
|
|
data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}]
|
|
data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}]
|
|
a, b = compute_milestone(data, 1500.0)
|
|
# 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장
|
|
assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False)
|
|
# 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0
|
|
assert (b["total"], b["pick"]) == (0, False)
|
|
data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리)
|
|
a2, _ = compute_milestone(data, 1500.0)
|
|
assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False)
|
|
# TS 짝 거울
|
|
for d in (data, _data()):
|
|
ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts",
|
|
f"computeMilestone({json.dumps(d)}, 1500)") # fmt: skip
|
|
assert ts == compute_milestone(d, 1500.0)
|
|
# 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장
|
|
saved = ms.Milestone.model_validate(data).model_dump()
|
|
assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5
|
|
assert saved["bases"][0]["legs"][0]["road"] is None
|
|
assert ms.Leg.model_validate({"km": 1}).road == "2차포장"
|
|
|
|
|
|
def test_화면_길찾기_단추_키_화면에_없음():
|
|
read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731
|
|
modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
|
|
state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
|
|
table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
|
# 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번
|
|
leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
|
|
assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal
|
|
assert "/quantity/milestone/directions" in state and "segmentEnds(" in state
|
|
assert '"도로 구분 고르기"' not in table and "r.pick" not in table # 152 자리로 정함
|
|
assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름
|
|
# 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음
|
|
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"):
|
|
text = rel.read_text(encoding="utf-8")
|
|
assert "KAKAO_REST_API_KEY" not in text and "KakaoAK" not in text, rel
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("AISLO_KAKAO_LIVE") != "1", reason="실제 카카오 = 한 번만 손으로"
|
|
)
|
|
def test_실제_카카오_한번():
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv(ROOT / ".env")
|
|
got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK))
|
|
assert got["reason"] is None and isinstance(got["km"], float) and 5 < got["km"] < 20
|
|
print(f"\n실제 카카오 거창군청 → 병곡리 = {got['km']} km")
|