feat(A05 가이드 캡처): 장면마다 sessionStorage 비움 · clip · 번호 대상 찍기 전 스크롤 · before 단계 select · scroll

- 장면 첫 적재 때 sessionStorage 비움(window.name 표시 · 장면 안 새로고침은 지킴) · localStorage 프로젝트 · 테마 그대로
- clip · callouts 대상 scrollIntoView 뒤 자리 잼 — 화면 밖 대상 번호 어긋남 막음
- 단계 열쇠 select(value · Playwright select_option) · scroll(선택자) · 머리 설명 갱신
- 시험 test_539_guide_capture 덧(select · scroll 통과 · select value 없음 · scroll 빈 글 거절)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:59:56 +09:00
co-authored by Claude Opus 5.5
parent eba6e0c01d
commit 32d9df0de7
2 changed files with 40 additions and 7 deletions
+30 -6
View File
@@ -33,6 +33,8 @@
단계 = 아래 열쇠 중 하나 + 덧붙임
{"click": "<css>"} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래)
{"fill": "<css>", "value": "글"} 칸 채움
{"select": "<css>", "value": "값"} 고르기 칸(<select>) 값 고름(option value 또는 글)
{"scroll": "<css>"} 그 요소가 화면에 들어오게 스크롤
{"hover": "<css>"} 마우스 올림(툴팁)
{"contextmenu": "<css>"} 오른쪽 누름(누름 메뉴 열기)
{"press": "Escape"} 키 · {"press": "Enter", "sel": "<css>"} = 그 요소에서
@@ -49,6 +51,9 @@
- 확인창을 찍을 때만 `"confirm": true` 로 그 단추를 누름 — 그 장면은 after 에 누름 단계가 있어야 함([취소])
- 페이지의 /api 쓰기 요청(PUT · PATCH · DELETE · allow_post 밖 POST)은 모두 끊음 · 끊은 요청은 표에 적음
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
- 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 ·
말만 세움
- clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼
- `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음)
3D(WebGL) · 지도(카카오) · CAD(iframe)
@@ -80,7 +85,9 @@ STEP_TIMEOUT = 15000
SETTLE_MS = 500
CLIP_PAD = 16
PROJECT_KEYS = ("road", "stab", "plan", "none")
STEP_KINDS = ("click", "contextmenu", "fill", "hover", "press", "eval", "wait", "wait_ms")
STEP_KINDS = (
"click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll",
) # fmt: skip
SCENE_KEYS = {
"name", "route", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
"callouts", "after", "allow_post",
@@ -96,6 +103,7 @@ DANGER_RE = re.compile(
SAFE_RE = re.compile(r"취소|닫기|\b(cancel|close)", re.I)
THEME_KEY, LANG_KEY, PROJECT_KEY = "frd_theme", "frd_lang", "frd_current_project_id"
CALLOUT_CLASS = "__guide_callout"
SEEDED = "__guide_seeded" # 탭 이름(window.name)에 남겨 첫 적재를 가림
YELLOW = "#FFD400"
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
@@ -142,8 +150,8 @@ def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
if not isinstance(val, str) or not val.strip():
errs.append(f"{at} {kind} 값이 빈 글")
continue
if kind == "fill" and not isinstance(st.get("value"), str):
errs.append(f"{at} fill 은 value(글) 필요")
if kind in ("fill", "select") and not isinstance(st.get("value"), str):
errs.append(f"{at} {kind} 은 value(글) 필요")
if st.get("confirm"):
if kind != "click":
errs.append(f"{at} confirm 은 click 에만")
@@ -356,6 +364,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
page.wait_for_timeout(st["wait_ms"])
elif "wait" in st:
_locate(page, st["wait"], frame).wait_for(state="visible", timeout=timeout)
elif "scroll" in st:
_locate(page, st["scroll"], frame).scroll_into_view_if_needed(timeout=timeout)
elif "eval" in st:
target = page.frame_locator(frame).locator(":root") if frame else page
target.evaluate(f"() => ({st['eval']})")
@@ -365,7 +375,7 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
else:
page.keyboard.press(st["press"])
else:
kind = next(k for k in ("click", "contextmenu", "fill", "hover") if k in st)
kind = next(k for k in ("click", "contextmenu", "fill", "select", "hover") if k in st)
loc = _locate(page, st[kind], frame)
loc.wait_for(state="visible", timeout=timeout)
if kind == "click":
@@ -377,6 +387,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
loc.click(button="right", timeout=timeout)
elif kind == "fill":
loc.fill(st["value"], timeout=timeout)
elif kind == "select":
loc.select_option(st["value"], timeout=timeout)
else:
loc.hover(timeout=timeout)
@@ -396,7 +408,7 @@ def _box(page: Any, sel: str, frame: str | None, what: str) -> dict[str, float]:
def shoot_scene(
context: Any, base: str, scene: dict[str, Any], pid: str | None
) -> tuple[bytes, list[str]]:
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭마다 sessionStorage 새로."""
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭 · 첫 적재 때 sessionStorage 비움."""
vw, vh = scene.get("viewport") or VIEWPORT
allow = scene.get("allow_post") or []
blocked: list[str] = []
@@ -406,7 +418,10 @@ def shoot_scene(
seed = json.dumps([THEME_KEY, LANG_KEY, PROJECT_KEY, pid])
page.add_init_script(
f"(([t, l, k, pid]) => {{ localStorage.setItem(t, 'light'); localStorage.setItem(l, 'ko');"
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k); }})({seed});"
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k);"
# 첫 적재만 비움 — 장면 안 새로고침 · 이동은 그 장면 조작을 지킴
f" if (!window.name.includes('{SEEDED}')) {{ sessionStorage.clear();"
f" window.name += '{SEEDED}'; }} }})({seed});"
)
page.on("dialog", lambda d: d.dismiss())
@@ -431,6 +446,15 @@ def shoot_scene(
run_steps(page, scene.get("before") or [])
page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS))
# 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음)
targets = [(c["sel"], c.get("frame")) for c in scene.get("callouts") or []]
if scene.get("clip"):
targets.insert(0, (scene["clip"], None))
for sel, frame in targets:
try:
_locate(page, sel, frame).scroll_into_view_if_needed(timeout=5000)
except Exception:
pass # 안 보이면 아래 _box 가 까닭을 냄
items, boxes = [], []
for c in scene.get("callouts") or []:
ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"),
+10 -1
View File
@@ -28,7 +28,14 @@ def _folder(scenes, manual=None) -> Path:
def test_good_scene_passes():
sc = _scene(
before=[{"click": ".card"}, {"wait": ".modal"}, {"wait_ms": 300}, {"hover": ".tip"}],
before=[
{"click": ".card"},
{"wait": ".modal"},
{"wait_ms": 300},
{"hover": ".tip"},
{"select": "#kind", "value": "plan"},
{"scroll": ".far"},
],
clip=".modal",
callouts=[{"n": 1, "sel": ".a"}, {"n": 2, "sel": ".b", "pos": "br"}],
after=[{"press": "Escape"}],
@@ -57,6 +64,8 @@ def test_name_rule(name):
{"callouts": [{"n": 1, "sel": ".a", "pos": "mid"}]},
{"before": [{"click": ".a", "wait": ".b"}]},
{"before": [{"fill": ".a"}]},
{"before": [{"select": ".a"}]},
{"before": [{"scroll": ""}]},
{"before": [{"jump": ".a"}]},
{"allow_post": ["projects"]},
{"extra": 1},