Files
Aislo/resources/tester/test_244_7_b08_milestone_kakao.py
T
eomsangdonandClaude Opus 5.5 2584325b16 feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)
- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 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
2026-10-11 14:07:14 +09:00

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