Files
Aislo/resources/tester/test_244_11_b08_milestone_auto_capture.py
T

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