Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""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
|