feat(A05 가이드 캡처): 걸음 mouse_click(좌표 · sel 기준 · 오른쪽 · 더블) · optional(요소 없으면 건너뜀) · 검사 부분 짝 파일로 분리 · 시험 덧

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 09:56:57 +09:00
co-authored by Claude Sonnet 5.5
parent 434cbcceb1
commit e3e3531647
3 changed files with 384 additions and 204 deletions
+78 -204
View File
@@ -44,7 +44,13 @@
{"eval": "<JS 식>"} 페이지에서 JS 실행(펼침 · 스크롤 등)
{"wait": "<css>"} 요소가 보일 때까지
{"wait_ms": 800} 쉼(ms)
덧붙임 — "frame": "<iframe css>"(그 안 요소 · B07 CAD = "iframe[src*='b07-cad']") · "timeout": ms(기본 15000)
{"mouse_click": [x, y]} 좌표 누름(page.mouse · CSS px · 캔버스 · 3D 처럼 요소가 없는 자리) ·
덧붙임 "sel": "<css>" = 그 요소 왼쪽 위 기준 상대 좌표(스크롤해 들어오게 함) ·
"button": "left"(기본) | "right" · "double": true = 더블클릭 ·
위험 단추 검사는 누른 자리 요소(가까운 단추 · 링크) 글자로
덧붙임 — "optional": true(요소가 있는 단계 어디든 · 요소가 3초 안에 안 보이면 그 단계만 건너뜀 ·
"있으면 누름" — 장면마다 열린 모양이 다른 카드 · 모달 · 누름 단계에 씀) ·
"frame": "<iframe css>"(그 안 요소 · B07 CAD = "iframe[src*='b07-cad']") · "timeout": ms(기본 15000)
선택자 = Playwright 선택자(css · `text=…` · `:has-text("…")` 모두 됨) · 번호 sel · clip 도 같음
frame 은 callouts 항목에도 씀
@@ -77,6 +83,21 @@ import time
from pathlib import Path
from typing import Any
from A05_EduDetail_Guide_Capture_Check import ( # noqa: F401 — 시험 · 바깥이 이 파일 이름으로 부름
ACCOUNT_KEYS,
ACCOUNTS,
PROJECT_KEYS,
SceneError,
callout_sels,
check_scene,
is_danger,
load_scenes,
manual_names,
pick_scenes,
sel_text,
step_target,
)
ROOT = Path(__file__).resolve().parent.parent
GUIDE = Path(__file__).resolve().parent / "guide"
PROJECTS_FILE = GUIDE / "_projects.json"
@@ -87,31 +108,10 @@ VIEWPORT = (1600, 900)
SCALE = 2
QUALITY = 85
STEP_TIMEOUT = 15000
OPTIONAL_MS = 3000 # optional 단계 · 요소를 기다리는 시간
SETTLE_MS = 500
CLIP_PAD = 16
PROJECT_KEYS = ("road", "stab", "plan", "none")
ACCOUNTS_FILE = ROOT / "tmp" / "verify_accounts.json"
ACCOUNT_KEYS = {
"company_admin": "회사관리자",
"user": "일반",
} # 장면 account → 파일 열쇠 (sys 는 CRED)
ACCOUNTS = ("sys", *ACCOUNT_KEYS)
STEP_KINDS = (
"click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll",
) # fmt: skip
SCENE_KEYS = {
"name", "route", "account", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
"callouts", "after", "allow_post",
} # fmt: skip
STEP_EXTRA = {"value", "confirm", "frame", "timeout", "sel"}
POSITIONS = ("tl", "tr", "bl", "br")
NAME_RE = re.compile(r"^(?P<page>[A-Z]\d{2})_\d{2}_[a-z0-9_]+$")
DANGER_RE = re.compile(
r"저장|확정|초기화|적용|삭제|제출|발송|전송|메일|(?<!내)보내기"
r"|\b(save|confirm|reset|apply|delete|remove|submit|send|mail)",
re.I,
)
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)에 남겨 첫 적재를 가림
@@ -119,181 +119,11 @@ YELLOW = "#FFD400"
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
class SceneError(Exception):
"""장면 하나 실패 — 까닭 한 줄."""
# ---------------------------------------------------------------------------
# 목록 읽기 · 검사(화면 없이 돎 — 지킴 시험 대상)
# 목록 읽기 · 검사 = A05_EduDetail_Guide_Capture_Check.py · 아래는 화면 없이 도는 계산
# ---------------------------------------------------------------------------
def is_danger(text: str) -> bool:
"""위험 단추 글자 · 선택자인가([취소] · [닫기] 는 아님)."""
return bool(text) and bool(DANGER_RE.search(text)) and not SAFE_RE.search(text)
SEL_TEXT_RE = re.compile(
r"""text\s*=\s*(?:"[^"]*"|'[^']*'|[^>]+)|:(?:has-)?text(?:-is|-matches)?\(\s*(?:"[^"]*"|'[^']*')""",
re.I,
)
def sel_text(sel: str) -> str:
"""선택자 속 글자 부분만(`text=` · `:has-text("…")`) — class · id 이름은 뺌."""
return " ".join(SEL_TEXT_RE.findall(sel or ""))
def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
"""단계 목록 검사 → (잘못, confirm 단계 있음)."""
if steps is None:
return [], False
if not isinstance(steps, list):
return [f"{where} 는 목록이어야 함"], False
errs, confirm = [], False
for i, st in enumerate(steps):
at = f"{where}[{i}]"
if not isinstance(st, dict):
errs.append(f"{at} 꼴 틀림")
continue
kinds = [k for k in STEP_KINDS if k in st]
if len(kinds) != 1:
errs.append(f"{at} 단계 열쇠는 하나({' · '.join(STEP_KINDS)})")
continue
kind = kinds[0]
extra = set(st) - {kind} - STEP_EXTRA
if extra:
errs.append(f"{at} 모르는 열쇠 {sorted(extra)}")
val = st[kind]
if kind == "wait_ms":
if not isinstance(val, (int, float)) or val < 0:
errs.append(f"{at} wait_ms 는 0 이상 수")
continue
if not isinstance(val, str) or not val.strip():
errs.append(f"{at} {kind} 값이 빈 글")
continue
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 에만")
confirm = True
elif is_danger(val if kind == "eval" else sel_text(val)) and kind in ("click", "eval"):
errs.append(
f"{at} 위험 단추 거절({kind}: {val[:40]}) — 확인창만이면 confirm + after [취소]"
)
if kind == "press" and is_danger(sel_text(st.get("sel", ""))):
errs.append(f"{at} 위험 요소에서 키 거절")
return errs, confirm
def check_scene(scene: Any, page: str) -> list[str]:
"""장면 하나 검사 → 잘못 목록(빈 목록 = 됨)."""
if not isinstance(scene, dict):
return ["장면은 사전이어야 함"]
errs: list[str] = []
name = scene.get("name")
m = NAME_RE.match(name) if isinstance(name, str) else None
if not m:
errs.append(f"이름 규칙 틀림({name!r}) — <페이지>_<두 자리>_<영문 소문자>")
elif m["page"] != page:
errs.append(f"이름 머리 {m['page']} ≠ 페이지 {page}")
extra = set(scene) - SCENE_KEYS
if extra:
errs.append(f"모르는 열쇠 {sorted(extra)}")
route = scene.get("route")
if not isinstance(route, str) or not route.startswith("#/"):
errs.append("route 는 '#/…' 해시 주소")
if scene.get("account", "sys") not in ACCOUNTS:
errs.append(f"account 는 {' · '.join(ACCOUNTS)}")
if scene.get("project", "none") not in PROJECT_KEYS:
errs.append(f"project 는 {' · '.join(PROJECT_KEYS)}")
vp = scene.get("viewport")
if vp is not None and not (
isinstance(vp, list) and len(vp) == 2 and all(isinstance(v, int) and v >= 320 for v in vp)
):
errs.append("viewport 는 [폭, 높이] 정수(320 이상)")
for key in ("settle_ms", "clip_pad"):
v = scene.get(key)
if v is not None and (not isinstance(v, (int, float)) or v < 0):
errs.append(f"{key} 는 0 이상 수")
clip = scene.get("clip")
if clip is not None and (not isinstance(clip, str) or not clip.strip()):
errs.append("clip 은 null 또는 선택자")
before_errs, confirm = _check_steps(scene.get("before"), "before")
after_errs, after_confirm = _check_steps(scene.get("after"), "after")
errs += before_errs + after_errs
if after_confirm:
errs.append("after 에는 confirm 단계 못 둠")
if confirm and not any("click" in s for s in scene.get("after") or [] if isinstance(s, dict)):
errs.append("confirm 단계가 있으면 after 에 누름 단계([취소]) 필요")
callouts = scene.get("callouts") or []
if not isinstance(callouts, list):
errs.append("callouts 는 목록")
callouts = []
seen: set[int] = set()
for i, c in enumerate(callouts):
if not isinstance(c, dict) or not callout_sels(c.get("sel")):
errs.append(f"callouts[{i}] 는 {{n, sel(글 또는 글 목록)}}")
continue
n = c.get("n")
if not isinstance(n, int) or isinstance(n, bool) or not 1 <= n <= 99:
errs.append(f"callouts[{i}] n 은 1~99 정수")
elif n in seen:
errs.append(f"callouts 번호 {n} 겹침")
else:
seen.add(n)
if c.get("pos", "tl") not in POSITIONS:
errs.append(f"callouts[{i}] pos 는 {' · '.join(POSITIONS)}")
allow = scene.get("allow_post")
if allow is not None and not (
isinstance(allow, list) and all(isinstance(a, str) and a.startswith("/api/") for a in allow)
):
errs.append("allow_post 는 '/api/…' 글 목록")
return errs
def load_scenes(folder: Path, page: str) -> list[dict[str, Any]]:
"""`_scenes.json` 읽고 검사 — 잘못이 하나라도 있으면 SceneError(전부 한꺼번에)."""
path = folder / "_scenes.json"
if not path.is_file():
raise SceneError(f"장면 목록 없음: {path}")
try:
scenes = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise SceneError(f"장면 목록 JSON 틀림: {e}") from e
if not isinstance(scenes, list):
raise SceneError("장면 목록은 [ … ] 목록")
errs = []
for i, s in enumerate(scenes):
label = s.get("name", f"#{i}") if isinstance(s, dict) else f"#{i}"
errs += [f"{label}: {e}" for e in check_scene(s, page)]
names = [s.get("name") for s in scenes if isinstance(s, dict)]
errs += [f"이름 겹침: {n}" for n in sorted({n for n in names if names.count(n) > 1})]
if errs:
raise SceneError("장면 목록 잘못\n " + "\n ".join(errs))
return scenes
def manual_names(folder: Path) -> set[str]:
"""`_manual.json` 손 그림 이름(.webp 뗌) — 목록이든 사전이든 · 없으면 빈 모음."""
path = folder / "_manual.json"
if not path.is_file():
return set()
data = json.loads(path.read_text(encoding="utf-8"))
return {str(n).removesuffix(".webp") for n in data}
def pick_scenes(scenes: list[dict[str, Any]], wanted: list[str] | None) -> list[dict[str, Any]]:
"""--scene 고르기 — 이름 같음 또는 앞머리 · 맞는 것 없으면 SceneError."""
if not wanted:
return scenes
picked = [s for s in scenes if any(s["name"] == w or s["name"].startswith(w) for w in wanted)]
if not picked:
raise SceneError(f"고른 장면 없음: {wanted}")
return picked
def resolve_project(key: str, cli_id: str | None, projects: dict[str, str]) -> str | None:
"""프로젝트 열쇠 → id(none = None) · --project-id 우선 · 없으면 SceneError."""
if key == "none":
@@ -314,14 +144,6 @@ def save_webp(png: bytes, path: Path) -> tuple[int, int, float]:
return img.width, img.height, round(path.stat().st_size / 1024, 1)
def callout_sels(sel: Any) -> list[str]:
"""callouts sel(글 또는 글 목록) → 선택자 목록 · 꼴이 틀리면 빈 목록."""
items = [sel] if isinstance(sel, str) else sel
if not isinstance(items, list) or not items:
return []
return items if all(isinstance(x, str) and x.strip() for x in items) else []
def union_box(boxes: list[dict[str, float]]) -> dict[str, float]:
"""요소 상자들(x · y · width · height)의 둘레 상자 하나."""
x0, y0 = min(b["x"] for b in boxes), min(b["y"] for b in boxes)
@@ -394,6 +216,13 @@ _DRAW_JS = """([cls, yellow, items]) => {
}
}"""
_CLEAR_JS = "(cls) => document.querySelectorAll('.' + cls).forEach((e) => e.remove())"
_POINT_JS = """([x, y]) => {
const e = document.elementFromPoint(x, y);
if (!e) return '';
const b = e.closest('button,a,[role=button],[title]') || e;
return [b.innerText, b.value, b.title, b.getAttribute('aria-label')]
.filter(Boolean).join(' ').slice(0, 200);
}"""
_TEXT_JS = (
"(e) => [e.innerText, e.value, e.title, e.getAttribute('aria-label')]"
".filter(Boolean).join(' ').slice(0, 200)"
@@ -408,11 +237,56 @@ def _locate(page: Any, sel: str, frame: str | None) -> Any:
return _root(page, frame).locator(sel).first
def _point_text(page: Any, x: float, y: float, frame: str | None) -> str:
"""화면 (x, y) 에 있는 요소(가까운 단추 · 링크 포함)의 글자 — frame 이면 그 안 요소."""
if not frame:
return page.evaluate(_POINT_JS, [x, y]) or ""
off = page.locator(frame).first.bounding_box() or {"x": 0, "y": 0}
root = page.frame_locator(frame).locator(":root")
return root.evaluate(f"(_, a) => ({_POINT_JS})(a)", [x - off["x"], y - off["y"]]) or ""
def _mouse_click(page: Any, st: dict[str, Any], frame: str | None, timeout: int) -> None:
"""좌표 누름 — sel 이 있으면 그 요소 왼쪽 위 기준 상대 좌표 · 누른 자리 요소 글자로 위험 검사."""
x, y = st["mouse_click"]
if st.get("sel"):
loc = _locate(page, st["sel"], frame)
loc.scroll_into_view_if_needed(timeout=timeout)
box = loc.bounding_box()
if not box:
raise SceneError(f"mouse_click 기준 요소 크기 없음: {st['sel'][:50]}")
x, y = box["x"] + x, box["y"] + y
text = _point_text(page, x, y, frame)
if is_danger(text):
raise SceneError(f"위험 단추 거절: '{text[:30]}'")
button = st.get("button", "left")
if st.get("double"):
page.mouse.dblclick(x, y, button=button)
else:
page.mouse.click(x, y, button=button)
def _absent(page: Any, st: dict[str, Any]) -> bool:
"""optional 단계의 요소가 없음(OPTIONAL_MS 안에 안 보임)."""
sel = step_target(st)
if not st.get("optional") or not sel:
return False
try:
_locate(page, sel, st.get("frame")).wait_for(state="visible", timeout=OPTIONAL_MS)
except Exception: # noqa: BLE001 — 없음 = 건너뜀
return True
return False
def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
"""단계 차례로 — 누르기 직전 실제 요소 글자로 위험 단추 한 번 더 검사."""
"""단계 차례로 — 누르기 직전 실제 요소 글자로 위험 단추 한 번 더 검사 · optional 은 요소 없으면 건너뜀."""
for st in steps:
frame, timeout = st.get("frame"), st.get("timeout", STEP_TIMEOUT)
if "wait_ms" in st:
if _absent(page, st):
continue
if "mouse_click" in st:
_mouse_click(page, st, frame, timeout)
elif "wait_ms" in st:
page.wait_for_timeout(st["wait_ms"])
elif "wait" in st:
_locate(page, st["wait"], frame).wait_for(state="visible", timeout=timeout)
@@ -0,0 +1,250 @@
"""가이드 캡처 장면 목록 읽기 · 검사(화면 없이 돎 — PLAN 539-1 · 요구 359-6).
`A05_EduDetail_Guide_Capture.py` 가 부르는 짝 파일 — 장면 꼴은 그 파일 머리 설명이 기준.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
PROJECT_KEYS = ("road", "stab", "plan", "none")
ACCOUNT_KEYS = {
"company_admin": "회사관리자",
"user": "일반",
} # 장면 account → 파일 열쇠 (sys 는 CRED)
ACCOUNTS = ("sys", *ACCOUNT_KEYS)
STEP_KINDS = (
"click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll",
"mouse_click",
) # fmt: skip
SCENE_KEYS = {
"name", "route", "account", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
"callouts", "after", "allow_post",
} # fmt: skip
STEP_EXTRA = {"value", "confirm", "frame", "timeout", "sel", "optional", "button", "double"}
POSITIONS = ("tl", "tr", "bl", "br")
NAME_RE = re.compile(r"^(?P<page>[A-Z]\d{2})_\d{2}_[a-z0-9_]+$")
DANGER_RE = re.compile(
r"저장|확정|초기화|적용|삭제|제출|발송|전송|메일|(?<!내)보내기"
r"|\b(save|confirm|reset|apply|delete|remove|submit|send|mail)",
re.I,
)
SAFE_RE = re.compile(r"취소|닫기|\b(cancel|close)", re.I)
class SceneError(Exception):
"""장면 하나 실패 — 까닭 한 줄."""
def callout_sels(sel: Any) -> list[str]:
"""callouts sel(글 또는 글 목록) → 선택자 목록 · 꼴이 틀리면 빈 목록."""
items = [sel] if isinstance(sel, str) else sel
if not isinstance(items, list) or not items:
return []
return items if all(isinstance(x, str) and x.strip() for x in items) else []
def is_danger(text: str) -> bool:
"""위험 단추 글자 · 선택자인가([취소] · [닫기] 는 아님)."""
return bool(text) and bool(DANGER_RE.search(text)) and not SAFE_RE.search(text)
SEL_TEXT_RE = re.compile(
r"""text\s*=\s*(?:"[^"]*"|'[^']*'|[^>]+)|:(?:has-)?text(?:-is|-matches)?\(\s*(?:"[^"]*"|'[^']*')""",
re.I,
)
def sel_text(sel: str) -> str:
"""선택자 속 글자 부분만(`text=` · `:has-text("…")`) — class · id 이름은 뺌."""
return " ".join(SEL_TEXT_RE.findall(sel or ""))
def step_target(st: dict[str, Any]) -> str | None:
"""단계가 가리키는 요소 선택자(없으면 None) — optional 이 있는지 볼 대상."""
if "press" in st or "mouse_click" in st:
return st.get("sel")
return next((st[k] for k in STEP_KINDS if k in st and k not in ("eval", "wait_ms")), None)
def _check_mouse(st: dict[str, Any], at: str) -> list[str]:
"""mouse_click 단계 검사 — [x, y] · sel · button · double."""
errs = []
xy = st["mouse_click"]
if not (
isinstance(xy, list)
and len(xy) == 2
and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in xy)
):
errs.append(f"{at} mouse_click 은 [x, y] 수 둘")
if "sel" in st and (not isinstance(st["sel"], str) or not st["sel"].strip()):
errs.append(f"{at} sel 은 선택자 글")
elif is_danger(sel_text(st.get("sel", ""))):
errs.append(f"{at} 위험 요소 기준 누름 거절({st['sel'][:40]})")
if st.get("confirm"):
errs.append(f"{at} confirm 은 click 에만")
if st.get("button", "left") not in ("left", "right"):
errs.append(f"{at} button 은 left · right")
if not isinstance(st.get("double", False), bool):
errs.append(f"{at} double 은 true · false")
return errs
def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
"""단계 목록 검사 → (잘못, confirm 단계 있음)."""
if steps is None:
return [], False
if not isinstance(steps, list):
return [f"{where} 는 목록이어야 함"], False
errs, confirm = [], False
for i, st in enumerate(steps):
at = f"{where}[{i}]"
if not isinstance(st, dict):
errs.append(f"{at} 꼴 틀림")
continue
kinds = [k for k in STEP_KINDS if k in st]
if len(kinds) != 1:
errs.append(f"{at} 단계 열쇠는 하나({' · '.join(STEP_KINDS)})")
continue
kind = kinds[0]
extra = set(st) - {kind} - STEP_EXTRA
if extra:
errs.append(f"{at} 모르는 열쇠 {sorted(extra)}")
val = st[kind]
if kind != "mouse_click" and ("button" in st or "double" in st):
errs.append(f"{at} button · double 은 mouse_click 에만")
if "optional" in st and (not isinstance(st["optional"], bool) or not step_target(st)):
errs.append(f"{at} optional 은 true · 요소(선택자)가 있는 단계에만")
if kind == "mouse_click":
errs += _check_mouse(st, at)
continue
if kind == "wait_ms":
if not isinstance(val, (int, float)) or val < 0:
errs.append(f"{at} wait_ms 는 0 이상 수")
continue
if not isinstance(val, str) or not val.strip():
errs.append(f"{at} {kind} 값이 빈 글")
continue
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 에만")
confirm = True
elif is_danger(val if kind == "eval" else sel_text(val)) and kind in ("click", "eval"):
errs.append(
f"{at} 위험 단추 거절({kind}: {val[:40]}) — 확인창만이면 confirm + after [취소]"
)
if kind == "press" and is_danger(sel_text(st.get("sel", ""))):
errs.append(f"{at} 위험 요소에서 키 거절")
return errs, confirm
def check_scene(scene: Any, page: str) -> list[str]:
"""장면 하나 검사 → 잘못 목록(빈 목록 = 됨)."""
if not isinstance(scene, dict):
return ["장면은 사전이어야 함"]
errs: list[str] = []
name = scene.get("name")
m = NAME_RE.match(name) if isinstance(name, str) else None
if not m:
errs.append(f"이름 규칙 틀림({name!r}) — <페이지>_<두 자리>_<영문 소문자>")
elif m["page"] != page:
errs.append(f"이름 머리 {m['page']} ≠ 페이지 {page}")
extra = set(scene) - SCENE_KEYS
if extra:
errs.append(f"모르는 열쇠 {sorted(extra)}")
route = scene.get("route")
if not isinstance(route, str) or not route.startswith("#/"):
errs.append("route 는 '#/…' 해시 주소")
if scene.get("account", "sys") not in ACCOUNTS:
errs.append(f"account 는 {' · '.join(ACCOUNTS)}")
if scene.get("project", "none") not in PROJECT_KEYS:
errs.append(f"project 는 {' · '.join(PROJECT_KEYS)}")
vp = scene.get("viewport")
if vp is not None and not (
isinstance(vp, list) and len(vp) == 2 and all(isinstance(v, int) and v >= 320 for v in vp)
):
errs.append("viewport 는 [폭, 높이] 정수(320 이상)")
for key in ("settle_ms", "clip_pad"):
v = scene.get(key)
if v is not None and (not isinstance(v, (int, float)) or v < 0):
errs.append(f"{key} 는 0 이상 수")
clip = scene.get("clip")
if clip is not None and (not isinstance(clip, str) or not clip.strip()):
errs.append("clip 은 null 또는 선택자")
before_errs, confirm = _check_steps(scene.get("before"), "before")
after_errs, after_confirm = _check_steps(scene.get("after"), "after")
errs += before_errs + after_errs
if after_confirm:
errs.append("after 에는 confirm 단계 못 둠")
if confirm and not any("click" in s for s in scene.get("after") or [] if isinstance(s, dict)):
errs.append("confirm 단계가 있으면 after 에 누름 단계([취소]) 필요")
callouts = scene.get("callouts") or []
if not isinstance(callouts, list):
errs.append("callouts 는 목록")
callouts = []
seen: set[int] = set()
for i, c in enumerate(callouts):
if not isinstance(c, dict) or not callout_sels(c.get("sel")):
errs.append(f"callouts[{i}] 는 {{n, sel(글 또는 글 목록)}}")
continue
n = c.get("n")
if not isinstance(n, int) or isinstance(n, bool) or not 1 <= n <= 99:
errs.append(f"callouts[{i}] n 은 1~99 정수")
elif n in seen:
errs.append(f"callouts 번호 {n} 겹침")
else:
seen.add(n)
if c.get("pos", "tl") not in POSITIONS:
errs.append(f"callouts[{i}] pos 는 {' · '.join(POSITIONS)}")
allow = scene.get("allow_post")
if allow is not None and not (
isinstance(allow, list) and all(isinstance(a, str) and a.startswith("/api/") for a in allow)
):
errs.append("allow_post 는 '/api/…' 글 목록")
return errs
def load_scenes(folder: Path, page: str) -> list[dict[str, Any]]:
"""`_scenes.json` 읽고 검사 — 잘못이 하나라도 있으면 SceneError(전부 한꺼번에)."""
path = folder / "_scenes.json"
if not path.is_file():
raise SceneError(f"장면 목록 없음: {path}")
try:
scenes = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise SceneError(f"장면 목록 JSON 틀림: {e}") from e
if not isinstance(scenes, list):
raise SceneError("장면 목록은 [ … ] 목록")
errs = []
for i, s in enumerate(scenes):
label = s.get("name", f"#{i}") if isinstance(s, dict) else f"#{i}"
errs += [f"{label}: {e}" for e in check_scene(s, page)]
names = [s.get("name") for s in scenes if isinstance(s, dict)]
errs += [f"이름 겹침: {n}" for n in sorted({n for n in names if names.count(n) > 1})]
if errs:
raise SceneError("장면 목록 잘못\n " + "\n ".join(errs))
return scenes
def manual_names(folder: Path) -> set[str]:
"""`_manual.json` 손 그림 이름(.webp 뗌) — 목록이든 사전이든 · 없으면 빈 모음."""
path = folder / "_manual.json"
if not path.is_file():
return set()
data = json.loads(path.read_text(encoding="utf-8"))
return {str(n).removesuffix(".webp") for n in data}
def pick_scenes(scenes: list[dict[str, Any]], wanted: list[str] | None) -> list[dict[str, Any]]:
"""--scene 고르기 — 이름 같음 또는 앞머리 · 맞는 것 없으면 SceneError."""
if not wanted:
return scenes
picked = [s for s in scenes if any(s["name"] == w or s["name"].startswith(w) for w in wanted)]
if not picked:
raise SceneError(f"고른 장면 없음: {wanted}")
return picked
@@ -266,3 +266,59 @@ def test_list_shows_account(capsys):
d = _folder([_scene(account="user")])
assert G.main(["--page", "B05", "--out", str(d), "--list"]) == 0
assert "user" in capsys.readouterr().out
# --- 359-6 걸음 둘 덧: mouse_click · optional ---
@pytest.mark.parametrize(
"step",
[
{"mouse_click": [10, 20]},
{"mouse_click": [10.5, 20], "sel": ".canvas", "button": "right", "double": True},
{"mouse_click": [0, 0], "sel": "text=닫기", "optional": True, "frame": "iframe"},
{"click": ".card", "optional": True},
{"contextmenu": ".n", "optional": True},
{"hover": ".n", "optional": True},
{"wait": ".n", "optional": True},
{"press": "Escape", "sel": ".n", "optional": True},
],
)
def test_new_steps_pass(step):
assert G.check_scene(_scene(before=[step]), "B05") == []
@pytest.mark.parametrize(
"step",
[
{"mouse_click": [1]},
{"mouse_click": "1,2"},
{"mouse_click": [True, 2]},
{"mouse_click": [1, "2"]},
{"mouse_click": [1, 2], "button": "middle"},
{"mouse_click": [1, 2], "double": "yes"},
{"mouse_click": [1, 2], "sel": ""},
{"mouse_click": [1, 2], "sel": "text=저장"},
{"mouse_click": [1, 2], "confirm": True},
{"mouse_click": [1, 2], "optional": True}, # 요소 없는 단계
{"press": "Escape", "optional": True},
{"wait_ms": 100, "optional": True},
{"eval": "1", "optional": True},
{"click": ".a", "optional": "yes"},
{"click": ".a", "button": "right"},
{"click": ".a", "double": True},
{"mouse_click": [1, 2], "click": ".a"},
],
)
def test_new_steps_rejected(step):
assert G.check_scene(_scene(before=[step]), "B05")
def test_step_target():
assert G.step_target({"click": ".a"}) == ".a"
assert G.step_target({"press": "Enter", "sel": ".b"}) == ".b"
assert G.step_target({"mouse_click": [1, 2], "sel": ".c"}) == ".c"
assert G.step_target({"mouse_click": [1, 2]}) is None
assert G.step_target({"press": "Enter"}) is None
assert G.step_target({"wait_ms": 5}) is None
assert G.step_target({"eval": "1"}) is None