feat(b08): 거리이정표 경로 지도 테일스케일 주소 안내 (244-10)

- 보안 문맥 아닌 주소(getDisplayMedia 없음) = [지도 캡처] 끔 + 까닭 한 줄 「캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」
- SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 필요 안내 한 줄 · 사진 올리기
- 새 시험 test_244_10 · test_244_9 에 보안 문맥 navigator 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 22:49:44 +09:00
co-authored by Claude Opus 5.5
parent 0a6f4f5ec1
commit 50cc45731c
3 changed files with 132 additions and 5 deletions
@@ -8,6 +8,8 @@
* 시점(`mapMarks`) · 경로 전체 맞춤
* [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) →
* 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤
* (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 ·
* SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
@@ -22,6 +24,8 @@ import {
export const NO_MAP =
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요";
export const NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요";
const SDK_FAIL = "KakaoSdkFail";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
@@ -63,7 +67,15 @@ export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
const script = document.createElement("script");
script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`;
script.onload = () => done();
script.onerror = () => fail(new Error(NO_MAP));
script.onerror = () =>
fail(
Object.assign(
new Error(
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`,
),
{ name: SDK_FAIL },
),
);
document.head.append(script);
});
}
@@ -142,6 +154,8 @@ export function routeMapView(
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로)
const canCapture = Boolean(navigator.mediaDevices?.getDisplayMedia); // http IP 주소 = 없음
const shot = canCapture ? "[지도 캡처] = 이 지도 칸 → 경로 그림" : NO_CAPTURE;
const draw = (): void => {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
@@ -192,13 +206,13 @@ export function routeMapView(
map.setBounds(bounds);
shown = sig;
hint.textContent = lines.length
? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림"
: "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림";
? `길 선 = 카카오 길찾기 · ${shot}`
: `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`;
}),
() => {
(error: unknown) => {
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로)
capture.hidden = true;
hint.textContent = NO_MAP;
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
@@ -207,6 +221,7 @@ export function routeMapView(
label: "지도 캡처",
size: "sm",
variant: "ghost",
disabled: !canCapture,
onClick: () =>
void captureElement(box)
.then((file) => uploadImage(projectId, routeId, file))
@@ -216,6 +231,7 @@ export function routeMapView(
}),
});
capture.dataset.b08MilestoneCapture = "";
if (!canCapture) capture.title = NO_CAPTURE;
const root = el("div", {
className: "b08-milestone__map-wrap",
children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box],
@@ -0,0 +1,109 @@
"""244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님).
① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로)
② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김
(가짜 kakao.maps · document 로 node 에서 돌림)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts"
ORIGIN = "http://100.72.32.27:5173"
NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
STUBS = f"""
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 }}), disabled: !!o.disabled,
title: "", onClick: o.onClick }});
const showToast = () => {{}};
const uploadImage = async () => {{}};
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const request = async () => ({{ key: "fake-js-key" }});
const injected: string[] = [];
(globalThis as any).window = globalThis;
(globalThis as any).location = {{ origin: {json.dumps(ORIGIN)} }};
const liveMilestone = () => ({{ site: {{}}, base: {{ enabled: false }},
routes: [{{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: false }}] }});
const siteInfo = () => ({{ start_lonlat: [127.83, 35.64] }});
const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]];
"""
FAKE_KAKAO = """
class Fake { setMap() {} relayout() {} setBounds() {} extend() {} }
(globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake,
LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } };
"""
def _view(setup):
script = (
STUBS
+ setup
+ IMPORTS.sub("", _read(KMAP))
+ """
const view = routeMapView("p1", "r1");
await new Promise((r) => setTimeout(r, 30));
const [bar, hint, box] = view.root.children;
const cap = bar.children[0];
console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden,
capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title }));
"""
)
src = IMPORTS.sub("", _read(LAYOUT)) + script
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
NO_MEDIA = """
Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} });
(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } };
"""
def test_보안_문맥_아님_캡처_끔_까닭_한_줄():
got = _view(NO_MEDIA + FAKE_KAKAO)
assert not got["boxHidden"] and not got["capHidden"] # 지도는 그대로 뜸
assert got["capDisabled"] and got["capTitle"] == NO_CAPTURE
assert got["hint"] == f"길 선 = 카카오 길찾기 · {NO_CAPTURE}"
assert "[지도 캡처] =" not in got["hint"]
def test_보안_문맥_캡처_켜짐():
media = """
Object.defineProperty(globalThis, "navigator", { configurable: true,
value: { mediaDevices: { getDisplayMedia: async () => ({}) } } });
(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } };
"""
got = _view(media + FAKE_KAKAO)
assert not got["capDisabled"] and got["capTitle"] == ""
assert got["hint"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림"
def test_SDK_401_도메인_등록_안내():
sdk_401 = """
(globalThis as any).document = { createElement: () => ({}),
head: { append: (s: any) => (injected.push(s.src), setTimeout(() => s.onerror(), 0)) } };
"""
got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror
assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0]
assert got["hint"] == (
f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"
)
assert got["boxHidden"] and got["capHidden"]
@@ -152,6 +152,8 @@ const injected: string[] = [];
(globalThis as any).document = { createElement: () => ({}),
head: { append: (s: any) => injected.push(s.src) } };
(globalThis as any).window = globalThis;
Object.defineProperty(globalThis, "navigator", { configurable: true,
value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); // localhost = 보안 문맥
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] });