diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py new file mode 100644 index 000000000..635bf9e88 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -0,0 +1,142 @@ +"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge. + +[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 → +서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로 +카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png` +(사용자 그림이 있으면 화면은 사용자 그림 우선). +페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture — +page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨). +경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 · +키 값은 돌려주지도 적지도 않음. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +import urllib.parse +from pathlib import Path +from typing import Any + +KAKAO_JS = "KAKAO_JS_KEY" +ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN" +DEFAULT_ORIGIN = "http://localhost:5173" +PAGE = "/__route_capture" +WIDTH, HEIGHT = 1200, 800 +TIMEOUT_S = 20 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 +TAIL = " — 그림 올리기를 쓰세요." + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) + + +class CaptureError(Exception): + """까닭 한 줄(키 · 예외 원문 없음).""" + + +def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" + data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") + sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" + return f""" +
+ +""" + + +def shoot(html: str, origin: str) -> bytes: + """헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄).""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import TimeoutError as PwTimeout + from playwright.sync_api import sync_playwright + except ImportError as error: + raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error + url = origin + PAGE + deadline = time.monotonic() + TIMEOUT_S + + def left() -> float: + return max(1.0, deadline - time.monotonic()) * 1000 + + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True, timeout=left()) + break + except PwError: + continue + if browser is None: + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") + try: + page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) + page.route( + url, + lambda route: route.fulfill( + status=200, content_type="text/html; charset=utf-8", body=html + ), + ) + page.goto(url, timeout=left()) + page.wait_for_function("window.__done || window.__fail", timeout=left()) + if page.evaluate("window.__fail"): + raise CaptureError( + f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)" + ) + page.wait_for_timeout(500) # 마지막 타일 · 표지 그림 + return page.locator("#m").screenshot(timeout=left()) + finally: + browser.close() + except CaptureError: + raise + except PwTimeout as error: + raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error + + +def make_route_image( + out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] +) -> dict[str, Any]: + """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" + key = os.environ.get(KAKAO_JS, "").strip() + if not key: + return { + "saved": False, + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + } + if not any(lines) and not marks: + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") + with _lock: + try: + png = shoot(page_html(key, lines, marks), origin) + except CaptureError as error: + return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"} + out_dir.mkdir(parents=True, exist_ok=True) + new = out_dir / f".{route_id}.png.new" + new.write_bytes(png) + new.replace(out_dir / f"{route_id}.png") + return {"saved": True, "reason": None} diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 348f331d2..bc1e83b47 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -152,25 +152,6 @@ export function mapMarks( 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, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index a1d1affae..43465fa21 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -4,7 +4,11 @@ PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 - DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기(자동 그림까지) · + &user_only=true = 사용자 그림만(「자동 그림으로 되돌리기」 · 244-11) + POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · + 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · + 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 @@ -13,8 +17,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 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자 +그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 +그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). @@ -31,7 +36,7 @@ import urllib.error import urllib.parse import urllib.request from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, File, HTTPException, Query, UploadFile @@ -42,6 +47,7 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_json import atomic_write_json @@ -57,6 +63,7 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" +AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" @@ -172,8 +179,8 @@ def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: } -def route_images(root: str | Path, route_id: str) -> list[Path]: - path = folder(root) +def route_images(root: str | Path, route_id: str, sub: str = "") -> list[Path]: + path = folder(root) / sub if not path.is_dir(): return [] return sorted( @@ -181,13 +188,13 @@ def route_images(root: str | Path, route_id: str) -> list[Path]: ) -def images(root: str | Path) -> dict[str, str]: - """경로 id → 그림 파일 이름(폴더가 목록).""" - path = folder(root) +def images(root: str | Path, sub: str = "") -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록 · sub = 하위 폴더 · 이름에 붙음).""" + path = folder(root) / sub if not path.is_dir(): return {} return { - f.stem: f.name + f.stem: f"{sub}/{f.name}" if sub else f.name for f in sorted(path.iterdir()) if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") } @@ -195,11 +202,13 @@ def images(root: str | Path) -> dict[str, str]: def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) + auto = images(root, AUTO) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), - "images": images(root), + "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 + "auto_images": auto, } @@ -209,9 +218,10 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for stem, name in images(root).items(): - if stem not in keep: - (path / name).unlink(missing_ok=True) + for sub in ("", AUTO): + for stem, name in images(root, sub).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) return payload(root, data) @@ -280,19 +290,54 @@ async def milestone_image_upload( async def milestone_image(project_id: UUID, route: str = Query(...)): route_id = _route_id(route) root = await _project_root(project_id) - found = route_images(root, route_id) + found = route_images(root, route_id) + route_images(root, route_id, AUTO) if not found: raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") - return FileResponse(found[0]) + return FileResponse(found[0]) # 사용자 그림 우선 @router.delete(PATH + "/image") -async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]: +async def milestone_image_delete( + project_id: UUID, route: str = Query(...), user_only: bool = False +) -> dict[str, Any]: route_id = _route_id(route) root = await _project_root(project_id) - for old in route_images(root, route_id): + auto = route_images(root, route_id, AUTO) + for old in route_images(root, route_id) + ([] if user_only else auto): old.unlink(missing_ok=True) - return {"name": None} + return {"name": f"{AUTO}/{auto[0].name}" if user_only and auto else None} + + +class Mark(BaseModel): + name: str = Field(default="", max_length=100) + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +Lat = Annotated[float, Field(ge=-90, le=90)] +Lon = Annotated[float, Field(ge=-180, le=180)] + + +class AutoImage(BaseModel): + """길 선 = 구간마다 [위도, 경도] 목록(마지막 [길찾기] 응답) · 표지 = 거래처 · 기준점 · 시점.""" + + lines: list[Annotated[list[tuple[Lat, Lon]], Field(max_length=20000)]] = Field( + default_factory=list, max_length=20 + ) + marks: list[Mark] = Field(default_factory=list, max_length=10) + + +@router.post(PATH + "/image/auto") +async def milestone_image_auto( + project_id: UUID, body: AutoImage, route: str = Query(...) +) -> dict[str, Any]: + """경로 그림 자동(244-11) — 서버 헤드리스 캡처 → auto/<경로 id>.png · 좌표는 저장 안 함.""" + route_id = _route_id(route) + root = await _project_root(project_id) + lines = [[list(p) for p in line] for line in body.lines] + marks = [m.model_dump() for m in body.marks] + got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks) + return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 196c5539b..f15eb4bae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -1,30 +1,22 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 + [캡처] → 그 경로 그림. + * 거리이정표 경로 지도(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 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 - * (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 · - * SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 + * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) + * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout"; +import { el } from "@ui/ui_template_elements"; +import { mapMarks } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { - liveMilestone, - routePaths, - siteInfo, - uploadImage, -} from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State"; 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"; // 테마 무관 또렷한 길 선 @@ -88,74 +80,20 @@ export function loadKakaoMaps(projectId: string): Promise { return sdk; } -/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */ -export async function captureElement(target: HTMLElement): Promise { - 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 } } - ).CropTarget; - let cropped = false; - if (crop && track && "cropTo" in track) - try { - await (track as unknown as { cropTo(t: object): Promise }).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((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 { +/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ +export function routeMapView(projectId: string, routeId: string): 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 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); @@ -174,7 +112,6 @@ export function routeMapView( 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)); @@ -206,35 +143,19 @@ export function routeMapView( map.setBounds(bounds); shown = sig; hint.textContent = lines.length - ? `길 선 = 카카오 길찾기 · ${shot}` - : `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`; + ? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + : "[길찾기]를 누르면 길 선 · 경로 그림이 생김"; }), (error: unknown) => { - box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) - capture.hidden = true; + box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로) hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; }, ); }; - const capture = createButton({ - label: "지도 캡처", - size: "sm", - variant: "ghost", - disabled: !canCapture, - 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 = ""; - 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], + children: [hint, box], }); draw(); return { root, draw }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index f61314eb1..411f9c173 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -10,7 +10,8 @@ * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) - * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림) + * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤 + * 서버 자동 캡처(244-11) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -241,9 +242,7 @@ 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 mapView = routeMapView(projectId, routeId); /** 모달 값 → 캐시(고침 · 길찾기 앞) */ const apply = (): boolean => { const fresh = liveMilestone(projectId); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index ac799ae0a..d61fee88b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -2,12 +2,12 @@ * B08_Quantity_UI_Milestone_Route.ts * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · - * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 - * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 - * 그림 = 올리는 즉시 서버(교체 · 지우기) - * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 · - * JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로) + * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 + * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 + * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 + * [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) · + * 도착 = 목적지 좌표) — 보기만 + * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄 * ========================================================================== */ import { @@ -19,11 +19,13 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; import { + autoImageNote, + canRevertImage, deleteImage, imageBlobUrl, liveMilestone, putMilestone, - routeImages, + routeImageKey, siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -84,41 +86,19 @@ export function routePoints( return points; } -/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */ -async function captureWindow(): Promise { - const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); - try { - const video = el("video"); - video.muted = true; - video.srcObject = stream; - await video.play(); - await new Promise((done) => requestAnimationFrame(done)); - const canvas = el("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext("2d")?.drawImage(video, 0, 0); - const blob = await new Promise((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 function routeView(projectId: string, routeId: string): MilestoneView { const title = el("h4"); - const hint = el("p", { - className: "b08-options__hint", - text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림", - }); + const HINT = + "경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; + const hint = el("p", { className: "b08-options__hint", text: HINT }); + hint.dataset.b08MilestoneRouteHint = ""; // 그림 — 경로 하나 = 그림 하나 const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; const loadImage = (force = false): void => { - const name = routeImages()[routeId] ?? null; + const name = routeImageKey(routeId); if (!force && name === shown) return; shown = name; img.hidden = !name; @@ -161,23 +141,22 @@ export function routeView(projectId: string, routeId: string): MilestoneView { }, }); kakao.dataset.b08MilestoneKakao = ""; - const capture = createButton({ - label: "화면 캡처", - size: "sm", - variant: "ghost", - onClick: () => - void captureWindow().then(put, (error: unknown) => { - if ((error as Error)?.name !== "NotAllowedError") - showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error"); - }), - }); - capture.dataset.b08MilestoneCapture = ""; const upload = createButton({ label: "그림 올리기", size: "sm", variant: "ghost", onClick: () => file.click(), }); + const revert = createButton({ + label: "자동 그림으로 되돌리기", + size: "sm", + variant: "ghost", + onClick: () => + void deleteImage(projectId, routeId, true).catch((error: unknown) => + showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"), + ), + }); + revert.dataset.b08MilestoneRevert = ""; const removeImage = createButton({ label: "그림 지우기", size: "sm", @@ -188,7 +167,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); - const mapView = routeMapView(projectId, routeId, () => loadImage(true)); + const mapView = routeMapView(projectId, routeId); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, @@ -196,7 +175,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { title, el("div", { className: "b08-milestone__bar", - children: [kakao, capture, upload, removeImage, file], + children: [kakao, upload, revert, removeImage, file], }), hint, mapView.root, @@ -212,6 +191,8 @@ 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(); + revert.hidden = !canRevertImage(routeId); + hint.textContent = autoImageNote(routeId) ?? HINT; mapView.draw(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 89d6da7dd..a92539d1b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -9,13 +9,16 @@ * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) + * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 + * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; -import { kmSegments, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; export interface SiteInfo { length_m: number | null; @@ -28,6 +31,7 @@ interface Payload { data: MilestoneData; site: SiteInfo; images: Record; + auto_images: Record; } export const EMPTY: MilestoneData = { @@ -55,6 +59,24 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; export const routeImages = (): Record => saved?.images ?? {}; +/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ +export const canRevertImage = (routeId: string): boolean => { + const name = saved?.images[routeId]; + const auto = saved?.auto_images?.[routeId]; + return !!name && !!auto && name !== auto; +}; + +/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ +const imageRev = new Map(); +export const routeImageKey = (routeId: string): string | null => { + const name = saved?.images[routeId]; + return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; +}; + +/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */ +const autoNotes = new Map(); +export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null; + export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; @@ -114,16 +136,58 @@ export async function uploadImage(projectId: string, routeId: string, file: File changes.dispatchEvent(new Event("change")); } -export async function deleteImage(projectId: string, routeId: string): Promise { - await call(imageUrl(projectId, routeId), { method: "DELETE" }); +/** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */ +export async function deleteImage( + projectId: string, + routeId: string, + userOnly = false, +): Promise { + const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : ""); + const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null }; if (saved) { const rest = { ...saved.images }; + const auto = { ...saved.auto_images }; delete rest[routeId]; + if (body.name) rest[routeId] = body.name; + else delete auto[routeId]; saved.images = rest; + saved.auto_images = auto; } changes.dispatchEvent(new Event("change")); } +/** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */ +export async function autoRouteImage( + projectId: string, + routeId: string, + lines: Array>, +): Promise { + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); + changes.dispatchEvent(new Event("change")); + let reason: string | null; + try { + const res = await request<{ name: string | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`, + { method: "POST", body: JSON.stringify({ lines, marks }) }, + ); + reason = res.reason; + if (res.name && saved) { + const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId]; + saved.auto_images = { ...saved.auto_images, [routeId]: res.name }; + if (!user) saved.images = { ...saved.images, [routeId]: res.name }; + imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); + } + } catch (error) { + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + } + if (reason) { + autoNotes.set(routeId, reason); + showToast(reason, "warning"); + } else autoNotes.delete(routeId); + changes.dispatchEvent(new Event("change")); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); @@ -177,6 +241,7 @@ export async function findRouteKm(projectId: string, routeId: string): Promise=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index 36226bd47..b1d8968e3 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -1,7 +1,7 @@ """244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님). -① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로) -② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김 +① getDisplayMedia 없음 = 지도 그대로 · 캡처 안내 없음([지도 캡처]는 244-11 에서 없앰 · 그림 = 서버 자동) +② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 숨김 (가짜 kakao.maps · document 로 node 에서 돌림) """ @@ -15,7 +15,6 @@ 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): @@ -25,10 +24,6 @@ def _read(rel): 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[] = []; @@ -55,10 +50,9 @@ def _view(setup): + """ const view = routeMapView("p1", "r1"); await new Promise((r) => setTimeout(r, 30)); -const [bar, hint, box] = view.root.children; -const cap = bar.children[0]; +const [hint, box] = view.root.children; console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, - capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title })); + children: view.root.children.length })); """ ) src = IMPORTS.sub("", _read(LAYOUT)) + script @@ -77,23 +71,11 @@ Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} } """ -def test_보안_문맥_아님_캡처_끔_까닭_한_줄(): +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"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + assert not got["boxHidden"] and got["children"] == 2 # 지도는 그대로 · 캡처 단추 없음 + assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + assert "캡처는 localhost" not in got["hint"] def test_SDK_401_도메인_등록_안내(): @@ -106,4 +88,4 @@ def test_SDK_401_도메인_등록_안내(): assert got["hint"] == ( f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" ) - assert got["boxHidden"] and got["capHidden"] + assert got["boxHidden"] diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py new file mode 100644 index 000000000..055f0c979 --- /dev/null +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -0,0 +1,277 @@ +"""244-11 B08 거리이정표 경로 그림 = 서버 자동 캡처(사용자 139번). + +가짜 브라우저(playwright.sync_api 자리에 가짜 모듈)로: +① [길찾기] 길 선 · 표지 → 서버 캡처 → auto/<경로 id>.png · 페이지 주소 = KAKAO_CAPTURE_ORIGIN + + /__route_capture(page.route 로 채움) · 좌표 저장 0 · 키 값 응답 0 +② 사용자 그림 우선 · 「자동 그림으로 되돌리기」(user_only) · 그림 지우기 = 둘 다 · + 경로 지우고 [저장] = 자동 그림도 +③ 실패 까닭 한 줄 — 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 +④ 동시 요청 = 하나씩(잠금) · 화면 = 브라우저 캡처 길 없음 · [길찾기] 뒤 자동 · 되돌리기 단추 +""" + +import json +import sys +import threading +import time +import types +from pathlib import Path +from uuid import uuid4 + +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_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-map" +KEY = "fake-js-key-123" +LINES = [[[35.6866, 127.9095], [35.68, 127.89]], [[35.672, 127.87], [35.66, 127.85]]] +MARKS = [ + {"name": "거창군청", "lon": 127.9095, "lat": 35.6866}, + {"name": "시점", "lon": 127.85, "lat": 35.66}, +] + + +class Box: + """가짜 브라우저 기록 · 동작 모드.""" + + def __init__(self, mode="ok", delay=0.0): + self.mode, self.delay = mode, delay + self.channels, self.routed, self.goto, self.html = [], [], [], [] + self.active = self.peak = 0 + self.lock = threading.Lock() + + +def fake_playwright(box): + class Error(Exception): + pass + + class TimeoutError(Error): # noqa: A001 — playwright 이름 그대로 + pass + + class Page: + def route(self, url, handler): + box.routed.append(url) + self.handler = handler + + def goto(self, url, timeout): + box.goto.append(url) + fulfil = types.SimpleNamespace(fulfill=lambda **kw: box.html.append(kw["body"])) + self.handler(fulfil) + + def wait_for_function(self, expr, timeout): + assert timeout <= cap.TIMEOUT_S * 1000 + if box.mode == "timeout": + raise TimeoutError("Timeout 20000ms exceeded") + + def evaluate(self, expr): + return 1 if box.mode == "sdk_fail" else None + + def wait_for_timeout(self, ms): + pass + + def locator(self, selector): + assert selector == "#m" + return self + + def screenshot(self, timeout): + with box.lock: + box.active += 1 + box.peak = max(box.peak, box.active) + time.sleep(box.delay) + with box.lock: + box.active -= 1 + return PNG + + class Browser: + def new_page(self, viewport): + assert viewport == {"width": 1200, "height": 800} + return Page() + + def close(self): + pass + + class Chromium: + def launch(self, channel, headless, timeout): + box.channels.append(channel) + assert headless + if box.mode == "no_browser" or (box.mode == "no_edge" and channel == "msedge"): + raise Error(f"Chromium distribution '{channel}' is not found") + return Browser() + + class Ctx: + def __enter__(self): + return types.SimpleNamespace(chromium=Chromium()) + + def __exit__(self, *a): + return False + + return types.SimpleNamespace(Error=Error, TimeoutError=TimeoutError, sync_playwright=Ctx) + + +def _setup(tmp_path, monkeypatch, box, origin=None): + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box)) + monkeypatch.setenv(cap.KAKAO_JS, KEY) + if origin is None: + monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) + else: + monkeypatch.setenv(cap.ORIGIN_KEY, origin) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def _auto(client, base, route="r1"): + return client.post( + base + "/image/auto", params={"route": route}, json={"lines": LINES, "marks": MARKS} + ) + + +def test_자동_그림_저장_좌표_0(tmp_path, monkeypatch): + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": "auto/r1.png", "reason": None} + assert KEY not in res.text + auto = tmp_path / "B08_Quantity/milestone/auto/r1.png" + assert auto.read_bytes() == PNG + # 페이지 = 기본 주소 + /__route_capture 를 page.route 로 채움 · Edge 먼저 + assert box.routed == box.goto == ["http://localhost:5173/__route_capture"] + assert box.channels == ["msedge"] + html = box.html[0] + assert f"appkey={KEY}&autoload=false" in html and 'onerror="window.__fail=1"' in html + assert json.dumps(LINES) in html and "거창군청" in html and "tilesloaded" in html + assert "#E60046" in html and "1200px" in html and "800px" in html + # 좌표 저장 0 — 폴더엔 그림만 + files = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) + assert files == ["B08_Quantity/milestone/auto/r1.png"] + got = client.get(base).json() + assert got["images"] == {"r1": "auto/r1.png"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(base + "/image", params={"route": "r1"}).content == PNG + # 나쁜 좌표 · id = 거절 + bad = {"lines": [[[95, 127.9]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=bad).status_code == 422 + assert _auto(client, base, "../x").status_code == 400 + + +def test_주소_바꿈_Edge_없으면_Chrome(tmp_path, monkeypatch): + box = Box("no_edge") + client, base = _setup(tmp_path, monkeypatch, box, origin=" http://100.72.32.27:5173/ ") + assert _auto(client, base).json()["name"] == "auto/r1.png" + assert box.channels == ["msedge", "chrome"] + assert box.goto == ["http://100.72.32.27:5173/__route_capture"] + + +def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): + client, base = _setup(tmp_path, monkeypatch, Box()) + img = base + "/image" + client.post(img, params={"route": "r1"}, files={"file": ("u.jpg", b"user", "image/jpeg")}) + assert _auto(client, base).json()["name"] == "auto/r1.png" # 자동 그림은 늘 만듦 + got = client.get(base).json() + assert got["images"] == {"r1": "r1.jpg"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == b"user" # 사용자 그림 우선 + # 자동 그림으로 되돌리기 = 사용자 그림만 지움 + back = client.delete(img, params={"route": "r1", "user_only": "true"}).json() + assert back == {"name": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == PNG + # 그림 지우기 = 둘 다 + assert client.delete(img, params={"route": "r1"}).json() == {"name": None} + assert client.get(img, params={"route": "r1"}).status_code == 404 + assert client.get(base).json()["auto_images"] == {} + # 경로 지우고 [저장] = 자동 그림도 지움 · 남은 경로 것은 그대로 + _auto(client, base, "r1") + _auto(client, base, "r2") + client.post(img, params={"route": "r2"}, files={"file": ("u.png", b"u2", "image/png")}) + keep = {"routes": [{"id": "r2", "name": "김천역"}]} + saved = client.put(base, json=keep).json() + assert saved["images"] == {"r2": "r2.png"} and saved["auto_images"] == {"r2": "auto/r2.png"} + assert not (tmp_path / "B08_Quantity/milestone/auto/r1.png").exists() + + +def test_실패_까닭_한_줄(tmp_path, monkeypatch): + tail = " — 그림 올리기를 쓰세요." + cases = { + "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", + "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", + "sdk_fail": "경로 그림 자동 캡처 실패 — 카카오 지도 JS 가 캡처 주소(http://localhost:5173)" + "를 받지 않음(키 도메인 등록 확인)", + } + for mode, reason in cases.items(): + box = Box(mode) + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": None, "reason": reason + tail}, mode + assert KEY not in res.text + assert not (tmp_path / "B08_Quantity/milestone/auto").exists() # 실패 = 그림 없음 + # Playwright 없음 + monkeypatch.setitem(sys.modules, "playwright.sync_api", None) + assert _auto(client, base).json()["reason"] == ( + "경로 그림 자동 캡처 실패 — 서버에 Playwright 가 없습니다(pip install playwright)" + tail + ) + # 키 없음 = 브라우저 안 띄움 + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + monkeypatch.setenv(cap.KAKAO_JS, " ") + assert _auto(client, base).json() == { + "name": None, + "reason": "카카오 지도 JavaScript 키가 없습니다(.env KAKAO_JS_KEY)" + tail, + } + assert box.channels == [] + # 길 선 · 표지 없음 + monkeypatch.setenv(cap.KAKAO_JS, KEY) + empty = client.post(base + "/image/auto", params={"route": "r1"}, json={}).json() + assert empty["reason"] == "길 선 · 표지가 없어 지도 그림을 못 만듦" + tail + + +def test_동시_요청_하나씩(tmp_path, monkeypatch): + box = Box(delay=0.15) + _setup(tmp_path, monkeypatch, box) + out = tmp_path / "auto" + got = [] + + def one(i): + got.append(cap.make_route_image(out, f"r{i}", LINES, MARKS)) + + jobs = [threading.Thread(target=one, args=(i,)) for i in range(3)] + for job in jobs: + job.start() + for job in jobs: + job.join() + assert [g["saved"] for g in got] == [True] * 3 and box.peak == 1 + assert sorted(p.name for p in out.iterdir()) == ["r0.png", "r1.png", "r2.png"] + + +def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): + def read(name): + return (ROOT / "B08_Quantity" / name).read_text(encoding="utf-8") + + ui = ("UI_Milestone_KakaoMap", "UI_Milestone_Route", "UI_Milestone_Modal", "Milestone_Layout") + for name in (f"B08_Quantity_{n}.ts" for n in ui): + text = read(name) + assert "getDisplayMedia" not in text and "지도 캡처" not in text, name + assert "캡처는 localhost" not in text, name + state = read("B08_Quantity_UI_Milestone_State.ts") + assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state + assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state + assert "user_only=true" in state and "showToast(reason," in state + route = read("B08_Quantity_UI_Milestone_Route.ts") + assert ( + 'label: "자동 그림으로 되돌리기"' in route + and "deleteImage(projectId, routeId, true)" in route + ) + assert "revert.hidden = !canRevertImage(routeId);" in route + assert "hint.textContent = autoImageNote(routeId) ?? HINT;" in route + kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") + assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 + big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts") + for name in (f"B08_Quantity_{n}" for n in big): + assert len(read(name).splitlines()) <= 700 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 767a771f0..91f57ffe6 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -168,16 +168,13 @@ def test_화면_모달_팬줌_그림탭(): # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram - # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길 + # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert ( "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) assert "window.open(kakaoRouteUrl(points)" in route - assert "getDisplayMedia({ video: true, audio: false })" in route - assert ( - "captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route - ) + assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route assert 'label: "그림 올리기"' in route page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index bedc3e1ef..5ee1d655f 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -1,7 +1,7 @@ -"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번). +"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로(사용자 138번 · [지도 캡처]는 244-11 에서 없앰). 서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 · -화면 = 표지(거래처 · 기준점 · 시점) · 캡처 자르기 수치 · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · +화면 = 표지(거래처 · 기준점 · 시점) · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · 표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림). """ @@ -111,7 +111,7 @@ def test_JS_키_길(monkeypatch): assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} -def test_표지_셋_캡처_자르기(): +def test_표지_셋(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []}, @@ -126,8 +126,6 @@ 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] == [ @@ -137,23 +135,16 @@ console.log(JSON.stringify([ ] 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; -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] }); @@ -170,9 +161,8 @@ 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; +const [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) @@ -185,7 +175,7 @@ def test_키_없음_안내_한_줄(): assert got["hint"] == ( "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" ) - assert got["boxHidden"] and got["captureHidden"] and got["label"] == "지도 캡처" + assert got["boxHidden"] FAKE_KAKAO = """ @@ -211,26 +201,24 @@ 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"] + 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 "[지도 캡처]" in got["hint"] + assert 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 + assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 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 "routeMapView(projectId, routeId)" in route assert "mapView.draw();" in route state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감)