251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""가이드 캡처 장면 목록 읽기 · 검사(화면 없이 돎 — 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
|