feat(b08): 거리이정표 장소 그림 영구 저장 — place/<상자 id>.png · 저장 뒤 바뀐 상자만 캡처 · place_images (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
@@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
|
||||
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
|
||||
cap.page_html = lambda key, lines, marks: HTML
|
||||
cap.page_html = lambda key, lines, marks, view=None: HTML
|
||||
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
|
||||
|
||||
캡처는 가짜(make_place_image 자리)로:
|
||||
① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
|
||||
② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/<id>.png ·
|
||||
GET 답 place_images · place_times
|
||||
③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음
|
||||
④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음)
|
||||
"""
|
||||
|
||||
import sys
|
||||
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-place"
|
||||
DATA = {
|
||||
"base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68},
|
||||
"routes": [
|
||||
{"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866},
|
||||
{"id": "r2", "name": "좌표 없음"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Fake:
|
||||
"""가짜 캡처 — 부른 상자 기록 · ok=False 면 실패."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.ok = True
|
||||
|
||||
def __call__(self, out_dir, place_id, mark):
|
||||
self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"]))
|
||||
if not self.ok:
|
||||
return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"{place_id}.png").write_bytes(PNG)
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def _save(root, data):
|
||||
return ms.save_data(root, ms.Milestone.model_validate(data))
|
||||
|
||||
|
||||
def _fake(monkeypatch):
|
||||
fake = Fake()
|
||||
monkeypatch.setattr(ms, "make_place_image", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_장소_그림_페이지_작은_지도():
|
||||
html = cap.page_html(
|
||||
"k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW
|
||||
)
|
||||
assert "width:480px;height:320px" in html and "level: 4" in html
|
||||
assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌
|
||||
assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로
|
||||
|
||||
|
||||
def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
got = ms.payload(tmp_path, ms.read_data(tmp_path))
|
||||
assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"}
|
||||
assert set(got["place_times"]) == {"b1", "r1"}
|
||||
assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values())
|
||||
assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG
|
||||
prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8")
|
||||
assert "127.9095" not in prints and "35.6866" not in prints # 해시만
|
||||
|
||||
|
||||
def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
fake.calls.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert fake.calls == [] # 안 바뀜 = 캡처 0
|
||||
moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]}
|
||||
_save(tmp_path, moved)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["r1"]
|
||||
fake.calls.clear()
|
||||
(tmp_path / "B08_Quantity/milestone/place/b1.png").unlink()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1"]
|
||||
# 실패 = 지문 안 적음 → 다음에 다시
|
||||
fake.calls.clear()
|
||||
fake.ok = False
|
||||
_save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}})
|
||||
ms.refresh_images(tmp_path)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1", "b1"]
|
||||
|
||||
|
||||
def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch):
|
||||
_fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움
|
||||
assert got["place_images"] == {} and got["place_times"] == {}
|
||||
assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir())
|
||||
|
||||
|
||||
def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
|
||||
async def root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
base = f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
# [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄)
|
||||
assert client.put(base, json=DATA).status_code == 200
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
assert client.get(base).json()["place_images"]["r1"] == "place/r1.png"
|
||||
# 등록 · 끌기 뒤 화면이 부름
|
||||
fake.calls.clear()
|
||||
mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5}
|
||||
res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json()
|
||||
assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0
|
||||
assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)]
|
||||
fake.ok = False
|
||||
bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json()
|
||||
assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400
|
||||
# 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음
|
||||
assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG
|
||||
assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
|
||||
for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
|
||||
assert client.get(base + "/file", params={"name": name}).status_code == 422, name
|
||||
Reference in New Issue
Block a user