Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 22:46:51 +09:00
9 changed files with 579 additions and 15 deletions
@@ -134,6 +134,43 @@ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat):
];
}
/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */
export function mapMarks(
data: MilestoneData,
routeId: string,
start: LonLat,
): Array<{ name: string; lon: number; lat: number }> {
const route = data.routes.find((r) => r.id === routeId);
if (!route) return [];
const out: Array<{ name: string; lon: number; lat: number }> = [];
const add = (name: string, p: [number, number] | null): void => {
if (p) out.push({ name, lon: p[0], lat: p[1] });
};
add(route.name || "거래처", pointOf(route));
if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base));
add("시점", start ?? null);
return out;
}
/** 캡처 자르기(244-9 · CropTarget 없을 때) — 요소 자리(CSS px) × (영상 폭 ÷ 창 폭 = 보통
* devicePixelRatio) → 영상 안 자를 칸(영상 밖은 잘라 냄) */
export function cropBox(
rect: { left: number; top: number; width: number; height: number },
viewWidth: number,
videoWidth: number,
videoHeight: number,
): { sx: number; sy: number; sw: number; sh: number } {
const k = videoWidth / viewWidth;
const sx = Math.min(Math.max(0, Math.round(rect.left * k)), videoWidth);
const sy = Math.min(Math.max(0, Math.round(rect.top * k)), videoHeight);
return {
sx,
sy,
sw: Math.max(0, Math.min(videoWidth - sx, Math.round((rect.left + rect.width) * k) - sx)),
sh: Math.max(0, Math.min(videoHeight - sy, Math.round((rect.top + rect.height) * k) - sy)),
};
}
/** 자동 km 를 그 구간에(사용자 km 는 그대로) */
export function putAutoKm(
data: MilestoneData,
+24 -3
View File
@@ -9,6 +9,9 @@
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함)
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 ·
공개용 · .env KAKAO_JS_KEY · 없으면 null)
정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 ·
폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님).
@@ -312,8 +315,9 @@ def _kakao_open(request: urllib.request.Request) -> bytes:
def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]:
"""카카오 길찾기 → {km, reason} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄.
키 · 응답 원문 · 경로 좌표는 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만)."""
"""카카오 길찾기 → {km, reason, path} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄 ·
path = 경로 좌표 [위도, 경도] 목록(성공 때만 · 지도 길 선 · 저장 안 함).
키 · 응답 원문은 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만)."""
key = os.environ.get(KAKAO_KEY, "").strip()
if not key:
return {
@@ -348,10 +352,27 @@ def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]:
if route.get("result_code") != 0 or not isinstance(distance, (int, float)):
msg = str(route.get("result_msg") or "결과 없음")[:80]
return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."}
return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None}
path: list[list[float]] = []
try: # vertexes = 경도 · 위도 번갈아(도로마다)
for section in route.get("sections") or []:
for road in section.get("roads") or []:
v = [float(n) for n in road.get("vertexes") or []]
path += [[lat, lon] for lon, lat in zip(v[0::2], v[1::2])]
except (TypeError, ValueError, AttributeError):
path = [] # 깨진 좌표 = 길 선만 없음(km 는 그대로)
return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path}
@router.post(PATH + "/directions")
async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]:
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음)."""
return await asyncio.to_thread(directions_km, body.origin, body.destination)
KAKAO_JS = "KAKAO_JS_KEY"
@router.get(PATH + "/map-key")
async def milestone_map_key(project_id: UUID) -> dict[str, Any]:
"""카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄."""
return {"key": os.environ.get(KAKAO_JS, "").strip() or None}
@@ -164,3 +164,19 @@
.b08-milestone .is-pick-road {
color: var(--color-danger);
}
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */
.b08-milestone__map {
width: 100%;
height: 360px;
border: 1px solid var(--color-border);
}
.b08-milestone__map-label {
padding: 2px 6px;
border: 1px solid #3c3c3c;
background: #fff;
color: #141414;
font-size: 12px;
white-space: nowrap;
}
@@ -0,0 +1,225 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_KakaoMap.ts
* 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 + [캡처] → 그 경로 그림.
*
* SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 ·
* 시점(`mapMarks`) · 경로 전체 맞춤
* [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) →
* 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout";
import { request } from "./B08_Quantity_UI_Hydro";
import {
liveMilestone,
routePaths,
siteInfo,
uploadImage,
} from "./B08_Quantity_UI_Milestone_State";
export const NO_MAP =
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
type LatLng = object;
interface KMap {
setBounds(bounds: object): void;
relayout(): void;
}
interface KOverlay {
setMap(map: KMap | null): void;
}
interface KakaoMaps {
load(done: () => void): void;
LatLng: new (lat: number, lng: number) => LatLng;
LatLngBounds: new () => { extend(p: LatLng): void };
Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap;
Polyline: new (o: {
path: LatLng[];
strokeWeight: number;
strokeColor: string;
strokeOpacity: number;
}) => KOverlay;
Marker: new (o: { position: LatLng; title: string }) => KOverlay;
CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay;
}
let sdk: Promise<KakaoMaps> | null = null;
/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */
export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
sdk ??= (async () => {
const { key } = await request<{ key: string | null }>(
`/projects/${projectId}/quantity/milestone/map-key`,
);
if (!key) throw new Error(NO_MAP);
const w = window as unknown as { kakao?: { maps: KakaoMaps } };
if (!w.kakao?.maps) {
await new Promise<void>((done, fail) => {
const script = document.createElement("script");
script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`;
script.onload = () => done();
script.onerror = () => fail(new Error(NO_MAP));
document.head.append(script);
});
}
const maps = w.kakao?.maps;
if (!maps) throw new Error(NO_MAP);
await new Promise<void>((done) => maps.load(done));
return maps;
})();
sdk.catch(() => (sdk = null));
return sdk;
}
/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */
export async function captureElement(target: HTMLElement): Promise<File> {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
preferCurrentTab: true,
} as DisplayMediaStreamOptions);
try {
const [track] = stream.getVideoTracks();
const crop = (
window as unknown as { CropTarget?: { fromElement(e: Element): Promise<object> } }
).CropTarget;
let cropped = false;
if (crop && track && "cropTo" in track)
try {
await (track as unknown as { cropTo(t: object): Promise<void> }).cropTo(
await crop.fromElement(target),
);
cropped = true;
} catch {
cropped = false; // 다른 창을 골랐음 등 — 자리 수치로 자름
}
const video = el("video");
video.muted = true;
video.srcObject = stream;
await video.play();
await new Promise((done) => requestAnimationFrame(done));
const box = cropped
? { sx: 0, sy: 0, sw: video.videoWidth, sh: video.videoHeight }
: cropBox(
target.getBoundingClientRect(),
window.innerWidth,
video.videoWidth,
video.videoHeight,
);
const canvas = el("canvas");
canvas.width = box.sw;
canvas.height = box.sh;
canvas.getContext("2d")?.drawImage(video, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"),
);
return new File([blob], "capture.png", { type: "image/png" });
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
export interface RouteMapView {
root: HTMLElement;
/** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */
draw: () => void;
}
/** 경로 지도 칸 + [캡처] — 거래처 모달 · 경로 하위 탭 같은 한 벌 */
export function routeMapView(
projectId: string,
routeId: string,
onCaptured: () => void = () => {},
): RouteMapView {
const box = el("div", { className: "b08-milestone__map" });
const hint = el("p", { className: "b08-options__hint" });
hint.dataset.b08MilestoneMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로)
const draw = (): void => {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
const lines = routePaths(routeId);
const sig = JSON.stringify([marks, lines]);
if (map && sig === shown) return;
loadKakaoMaps(projectId).then(
(maps) =>
requestAnimationFrame(() => {
const points = [
...marks.map((m) => new maps.LatLng(m.lat, m.lon)),
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
];
if (!points.length) {
hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임";
return;
}
box.hidden = false;
capture.hidden = false;
map ??= new maps.Map(box, { center: points[0], level: 7 });
map.relayout();
drawn.forEach((o) => o.setMap(null));
drawn = [
...lines.map(
(line) =>
new maps.Polyline({
path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)),
strokeWeight: 6,
strokeColor: LINE_COLOR,
strokeOpacity: 0.9,
}),
),
...marks.flatMap((m) => {
const at = new maps.LatLng(m.lat, m.lon);
return [
new maps.Marker({ position: at, title: m.name }),
new maps.CustomOverlay({
position: at,
content: el("span", { className: "b08-milestone__map-label", text: m.name }),
yAnchor: 2.6,
}),
];
}),
];
drawn.forEach((o) => o.setMap(map));
const bounds = new maps.LatLngBounds();
points.forEach((p) => bounds.extend(p));
map.setBounds(bounds);
shown = sig;
hint.textContent = lines.length
? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림"
: "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림";
}),
() => {
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로)
capture.hidden = true;
hint.textContent = NO_MAP;
},
);
};
const capture = createButton({
label: "지도 캡처",
size: "sm",
variant: "ghost",
onClick: () =>
void captureElement(box)
.then((file) => uploadImage(projectId, routeId, file))
.then(onCaptured, (error: unknown) => {
if ((error as Error)?.name !== "NotAllowedError")
showToast(`지도를 캡처하지 못했습니다 — ${String(error)}`, "error");
}),
});
capture.dataset.b08MilestoneCapture = "";
const root = el("div", {
className: "b08-milestone__map-wrap",
children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box],
});
draw();
return { root, draw };
}
@@ -10,6 +10,7 @@
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 ·
* 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」)
* 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림)
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
@@ -38,6 +39,7 @@ import {
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
import {
findRouteKm,
legRoadOptions,
@@ -239,6 +241,9 @@ export function openRouteInfo(projectId: string, routeId: string): void {
let legs = legsEditor(route.legs);
const status = el("p", { className: "b08-options__hint" });
status.dataset.b08MilestoneInfo = "status";
const mapView = routeMapView(projectId, routeId, () =>
showToast("지도 캡처 = 이 거래처 경로 그림", "success"),
);
/** 모달 값 → 캐시(고침 · 길찾기 앞) */
const apply = (): boolean => {
const fresh = liveMilestone(projectId);
@@ -276,6 +281,7 @@ export function openRouteInfo(projectId: string, routeId: string): void {
const next = legsEditor(r.legs);
legs.root.replaceWith(next.root);
legs = next;
mapView.draw(); // 길 선(그때그때 · 저장 안 함)
},
});
find.dataset.b08MilestoneInfo = "find";
@@ -311,6 +317,8 @@ export function openRouteInfo(projectId: string, routeId: string): void {
status,
head("주소 · 지도"),
address.root,
head("경로 지도 — 카카오"),
mapView.root,
);
},
});
@@ -6,6 +6,8 @@
* 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로
* 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에
* 그림 = 올리는 즉시 서버(교체 · 지우기)
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 ·
* JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로)
* ========================================================================== */
import {
@@ -25,6 +27,7 @@ import {
siteInfo,
uploadImage,
} from "./B08_Quantity_UI_Milestone_State";
import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
let menu: MapContextMenu | null = null;
let lastCell: Element | null = null;
@@ -185,6 +188,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
),
});
const mapView = routeMapView(projectId, routeId, () => loadImage(true));
const root = el("section", {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
@@ -195,6 +199,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
children: [kakao, capture, upload, removeImage, file],
}),
hint,
mapView.root,
noImage,
img,
],
@@ -207,6 +212,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
loadImage();
mapView.draw();
};
refresh();
return { root, refresh };
+20 -10
View File
@@ -8,6 +8,7 @@
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
* [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 ·
* 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음)
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -133,12 +134,18 @@ export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));
/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */
export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions];
/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<Array<[number, number]>>>();
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
paths.get(routeId) ?? [];
/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 ·
* 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */
export async function findRouteKm(projectId: string, routeId: string): Promise<string[]> {
const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
const out: string[] = [];
const got: Array<[KmSegment["key"], number]> = [];
const lines: Array<Array<[number, number]>> = [];
for (const seg of segments) {
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
@@ -148,25 +155,28 @@ export async function findRouteKm(projectId: string, routeId: string): Promise<s
continue;
}
try {
const res = await request<{ km: number | null; reason: string | null }>(
`/projects/${projectId}/quantity/milestone/directions`,
{
method: "POST",
body: JSON.stringify({
origin: { lon: seg.from[0], lat: seg.from[1] },
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
},
);
const res = await request<{
km: number | null;
reason: string | null;
path?: Array<[number, number]>;
}>(`/projects/${projectId}/quantity/milestone/directions`, {
method: "POST",
body: JSON.stringify({
origin: { lon: seg.from[0], lat: seg.from[1] },
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
});
if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`);
else {
out.push(`${seg.text} ${res.km} km`);
got.push([seg.key, res.km]);
if ((res.path?.length ?? 0) >= 2) lines.push(res.path!);
}
} catch (error) {
out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`);
}
}
paths.set(routeId, lines);
if (got.length) {
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
for (const [key, km] of got) putAutoKm(fresh, routeId, key, km);
@@ -79,13 +79,14 @@ def test_가짜_응답_km_소수_한자리(monkeypatch):
monkeypatch.setattr(ms, "_kakao_open", fake)
res = _post(_client())
assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None}
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 만)
# 응답 원문은 안 돌려줌(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)):
@@ -0,0 +1,240 @@
"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번).
서버 = 길찾기 응답에 경로 좌표 [위도, 경도](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
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)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", 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(data)
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, base: {{ ...D.base, enabled: false }} }}, "r1", null),
cropBox({{ left: 100, top: 50, width: 400, height: 300 }}, 1000, 2000, 1200),
cropBox({{ left: -20, top: 500, width: 1100, height: 300 }}, 1000, 1000, 600),
]));""",
)
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}]
assert got[3] == {"sx": 200, "sy": 100, "sw": 800, "sh": 600} # × 2(devicePixelRatio)
assert got[4] == {"sx": 0, "sy": 500, "sw": 1000, "sh": 100} # 영상 밖은 잘라 냄
STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const createButton = (o: any) => ({ ...el("button", { text: o.label }), onClick: o.onClick });
const showToast = () => {};
const uploadImage = async () => {};
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}
const view = routeMapView("p1", "r1");
await new Promise((r) => setTimeout(r, 30));
const [bar, hint, box] = view.root.children;
console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden,
captureHidden: bar.children[0].hidden, label: bar.children[0].textContent,
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"] and got["captureHidden"] and got["label"] == "지도 캡처"
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); },
} };
"""
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 not got["captureHidden"] 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 "[지도 캡처]" in got["hint"]
def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "preferCurrentTab: true" in kmap and "crop.fromElement(target)" in kmap
assert "cropTo(" in kmap and "stream.getTracks().forEach((t) => t.stop())" in kmap
assert "uploadImage(projectId, routeId, file)" in kmap
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "mapView.root" in modal and "mapView.draw();" in modal
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId, () => loadImage(true))" in route
assert "mapView.draw();" in route
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(routeId, lines);" 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