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
+33 -13
View File
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
"""
from __future__ import annotations
@@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — [그림 변경]으로 넣으세요."
PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
@@ -45,17 +47,22 @@ class CaptureError(Exception):
"""까닭 한 줄(키 · 예외 원문 없음)."""
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
def page_html(
key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
level = view["level"] if view else 7
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
<div id="m" style="width:{w}px;height:{h}px"></div>
<script src="{sdk}" onerror="window.__fail=1"></script>
<script>window.kakao && kakao.maps.load(() => {{
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
D.marks.forEach((m) => {{
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
}});
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
if ({"false" if view else "true"}) {{
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
}}
}});</script></body></html>"""
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
key = os.environ.get(KAKAO_JS, "").strip()
try:
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
except CaptureError as error:
return {"saved": False, "reason": str(error)}
out_dir = Path(job["out"])
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
def make_route_image(
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
out_dir: Path,
route_id: str,
lines: list[Any],
marks: list[dict[str, Any]],
view: dict[str, int] | None = None,
what: str = "경로 그림",
tail: str = TAIL,
) -> dict[str, Any]:
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
key = os.environ.get(KAKAO_JS, "").strip()
if not key:
return {
"saved": False,
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
}
if not any(lines) and not marks:
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
with _lock:
got = spawn({**job, "origin": origin})
if not got.get("saved"):
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
return {"saved": True, "reason": None}
def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
"""장소 그림(256-1) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 id>.png`."""
return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
if __name__ == "__main__":
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
+109 -6
View File
@@ -9,6 +9,10 @@
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
지금 중점 측점(244-12 · 상자 작은 글씨)
@@ -24,6 +28,10 @@
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
(캐시 깨기).
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
@@ -32,9 +40,11 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import os
import threading
import urllib.error
import urllib.parse
import urllib.request
@@ -42,7 +52,7 @@ from pathlib import Path
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, ValidationError, model_validator
@@ -51,7 +61,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -69,6 +79,8 @@ PATH = "/{project_id}/quantity/milestone"
FOLDER = Path("B08_Quantity") / "milestone"
DATA_FILE = "milestone.json"
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
PLACE = "place" # 장소 그림 하위 폴더(256-1)
PRINTS = ".prints.json" # 그림 지문 {"place/<id>": 해시} — 바뀐 것만 다시 캡처
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
@@ -212,15 +224,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]:
}
def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]:
"""좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = "b1"."""
# ponytail: 단일 기준점 — 254-1 bases[] 합친 뒤 bases[].id 로
boxes = [(r["id"], r.get("name") or "", r.get("lon"), r.get("lat")) for r in data["routes"]]
base = data.get("base") or {}
boxes.append(("b1", base.get("name") or "기준점", base.get("lon"), base.get("lat")))
return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None]
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
site = site_info(root, data["site"]["mid_station_m"])
auto = images(root, AUTO)
place = images(root, PLACE)
return {
"data": data,
"site": site,
"table": compute_milestone(data, site["mid_default_m"]),
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
"auto_images": auto,
"place_images": place,
"place_times": {
stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items()
},
}
@@ -230,13 +256,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
atomic_write_json(path / DATA_FILE, data)
keep = {route["id"] for route in data["routes"]}
for sub in ("", AUTO):
for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})):
for stem, name in images(root, sub).items():
if stem not in keep:
if stem not in ids:
(path / name).unlink(missing_ok=True)
return payload(root, data)
def _print(*parts: Any) -> str:
"""지문 — 좌표 · 이름 해시(좌표 원문은 안 남김)."""
return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16]
def _read_prints(root: str | Path) -> dict[str, str]:
try:
return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _set_print(root: str | Path, key: str, value: str | None) -> None:
"""지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처)."""
with _prints_lock:
prints = _read_prints(root)
if prints.get(key) == value:
return
if value is None:
prints.pop(key, None)
else:
prints[key] = value
atomic_write_json(folder(root) / PRINTS, prints)
_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나
def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict:
"""장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}."""
got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat})
if got["saved"]:
_set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat))
return got
def refresh_images(root: str | Path) -> None:
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
실패 = 다음 [저장]에 다시."""
data = read_data(root)
prints = _read_prints(root)
have = images(root, PLACE)
for place_id, name, lon, lat in places(data):
if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
place_image(root, place_id, name, lon, lat)
def _route_id(route: str) -> str:
try:
return Route(id=route).id
@@ -260,9 +333,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]:
@router.put(PATH)
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
async def milestone_save(
project_id: UUID, body: Milestone, tasks: BackgroundTasks
) -> dict[str, Any]:
root = await _project_root(project_id)
return await asyncio.to_thread(save_data, root, body)
out = await asyncio.to_thread(save_data, root, body)
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
return out
@router.get(PATH + "/site-address")
@@ -375,6 +452,32 @@ async def milestone_image_auto(
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$"
@router.post(PATH + "/place-image")
async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict:
"""장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/<id>.png · 좌표는 저장 안 함."""
place_id = _route_id(id)
root = await _project_root(project_id)
got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat)
if not got["saved"]:
return {"name": None, "time": None, "reason": got["reason"]}
name = f"{PLACE}/{place_id}.png"
time_ms = int((folder(root) / name).stat().st_mtime * 1000)
return {"name": name, "time": time_ms, "reason": None}
@router.get(PATH + "/file")
async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)):
"""그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음)."""
root = await _project_root(project_id)
path = folder(root) / name
if not path.is_file():
raise HTTPException(status_code=404, detail="그림이 없습니다.")
return FileResponse(path)
KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
KAKAO_KEY = "KAKAO_REST_API_KEY"
@@ -13,6 +13,8 @@
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -35,6 +37,8 @@ interface Payload {
site: SiteInfo;
images: Record<string, string>;
auto_images: Record<string, string>;
place_images: Record<string, string>;
place_times: Record<string, number>;
}
export const EMPTY: MilestoneData = {
@@ -204,6 +208,43 @@ export async function autoRouteImage(
changes.dispatchEvent(new Event("change"));
}
/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */
export const placeImageKey = (placeId: string): string | null => {
const name = saved?.place_images?.[placeId];
return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null;
};
/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */
export async function capturePlace(
projectId: string,
placeId: string,
mark: { name: string; lon: number; lat: number },
): Promise<void> {
let reason: string | null;
try {
const res = await request<{ name: string | null; time: number | null; reason: string | null }>(
`/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`,
{ method: "POST", body: JSON.stringify(mark) },
);
reason = res.reason;
if (res.name && saved) {
saved.place_images = { ...saved.place_images, [placeId]: res.name };
saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() };
}
} catch (error) {
reason = `장소 그림 캡처 요청 실패(${String(error)})`;
}
if (reason) showToast(reason, "warning");
changes.dispatchEvent(new Event("change"));
}
/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */
export async function placeImageBlobUrl(projectId: string, placeId: string): Promise<string> {
const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`;
const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`;
return URL.createObjectURL(await (await call(url)).blob());
}
/** 그림 → 화면 주소(blob · 쿠키 포함) */
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
@@ -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