"""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.setattr( cap, "spawn", cap.capture ) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험) 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") # 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림 assert 'if (image && seg.key === "route" && paths.has(from))' in state assert "void autoRouteImage(projectId, from, routePaths(from));" 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") # 157 [초기화] = 사용자 그림만 지우고 길찾기 지도로 · 쓸데없는 안내 줄 없음 assert 'label: "초기화"' in route and "deleteImage(projectId, routeId, true)" in route assert "reset.hidden = !user;" in route and "HINT" not 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