Files
Aislo/A05_EduDetail/A05_EduDetail_Guide_Capture_Check.py
T

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