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:
2026-10-11 14:01:22 +09:00
co-authored by Claude Opus 5.5
parent c172c4a462
commit 8752bb0d1a
5 changed files with 330 additions and 20 deletions
@@ -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