feat(A05 가이드 캡처): 걸음 mouse_click(좌표 · sel 기준 · 오른쪽 · 더블) · optional(요소 없으면 건너뜀) · 검사 부분 짝 파일로 분리 · 시험 덧
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user