"""가이드 캡처 장면 목록 읽기 · 검사(화면 없이 돎 — 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[A-Z]\d{2})_\d{2}_[a-z0-9_]+$") DANGER_RE = re.compile( r"저장|확정|초기화|적용|삭제|제출|발송|전송|메일|(? 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