Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
- --force = `_manual.json` 그림도 찍음 · --headed = 창을 띄워 찍음(헤드리스에서 3D · 지도가 안 그려질 때)
|
||||
- 프로젝트 = A05_EduDetail/guide/_projects.json `{"road": "<id>", "stab": "<id>", "plan": "<id>"}`
|
||||
· --project-id 를 주면 그 id 가 모든 프로젝트 장면에 우선(자기 검증 프로젝트로 시험할 때)
|
||||
- 로그인 = tmp/aislo_cred.json(시스템 관리자) · 값은 어디에도 찍지 않음
|
||||
- 로그인 = 장면 칸 account 대로 — sys(기본) = tmp/aislo_cred.json · company_admin · user = tmp/verify_accounts.json
|
||||
의 「회사관리자」 · 「일반」 · 계정마다 컨텍스트를 따로 로그인(쓰는 것만 · 처음 쓸 때) · 값은 어디에도 찍지 않음
|
||||
- 끝에 표(장면 · 크기 · kB · 걸린 시간 · 실패 까닭) · 실패가 하나라도 있으면 종료 코드 1
|
||||
|
||||
`_scenes.json` 꼴 = 장면(그림 하나) 목록 — 이 머리 설명이 기준
|
||||
@@ -17,6 +18,7 @@
|
||||
{
|
||||
"name": "B05_00_overview", 필수 · <페이지>_<두 자리>_<영문 소문자·숫자·_> · 그림 = <name>.webp
|
||||
"route": "#/b05-profile", 필수 · 해시 주소("#/…") · vite 주소 뒤에 붙임
|
||||
"account": "sys", 선택 · sys(기본 · 시스템 관리자) | company_admin | user · 역할마다 화면이 다른 페이지용
|
||||
"project": "road", road | stab | plan | none(기본) · none = 현재 프로젝트 비움
|
||||
"viewport": [1600, 900], 선택 · 화면 크기(CSS px) · 기본 1600×900 · 그림은 늘 2배
|
||||
"before": [ 단계, … ], 선택 · 찍기 전 조작(아래 단계 꼴)
|
||||
@@ -25,7 +27,8 @@
|
||||
"clip_pad": 16, 선택 · clip 둘레 여백(CSS px · 기본 16)
|
||||
"callouts": [ 선택 · 노란 둥근 테두리 + 검은 원 흰 번호 · 번호 = 글 번호 표
|
||||
{"n": 1, "sel": "<css>", "pos": "tl"} pos = 번호 원 자리 tl(기본) · tr · bl · br(바깥 모서리)
|
||||
],
|
||||
{"n": 2, "sel": ["<css>", "<css>"]} sel 목록 = 여러 요소의 둘레를 하나로 묶은 상자 하나 + 번호 하나
|
||||
] 번호 원이 화면 밖으로 나가면 안쪽으로 밀어 넣음(넘칠 때만) · clip 은 원까지 넣어 자름
|
||||
"after": [ 단계, … ], 선택 · 찍은 뒤 조작(확인창 [취소] · 모달 닫기)
|
||||
"allow_post": ["/api/b05/…"] 선택 · 허락할 POST 주소 조각(읽기용 POST 만 · 아래 지킴)
|
||||
}
|
||||
@@ -53,6 +56,7 @@
|
||||
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
|
||||
- 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 ·
|
||||
말만 세움
|
||||
- 번호 원은 늘 화면 안 · clip 은 원을 포함하도록 넓힘(원이 잘리지 않음)
|
||||
- clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼
|
||||
- `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음)
|
||||
|
||||
@@ -85,11 +89,17 @@ STEP_TIMEOUT = 15000
|
||||
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", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
|
||||
"name", "route", "account", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
|
||||
"callouts", "after", "allow_post",
|
||||
} # fmt: skip
|
||||
STEP_EXTRA = {"value", "confirm", "frame", "timeout", "sel"}
|
||||
@@ -182,6 +192,8 @@ def check_scene(scene: Any, page: str) -> list[str]:
|
||||
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")
|
||||
@@ -209,8 +221,8 @@ def check_scene(scene: Any, page: str) -> list[str]:
|
||||
callouts = []
|
||||
seen: set[int] = set()
|
||||
for i, c in enumerate(callouts):
|
||||
if not isinstance(c, dict) or not isinstance(c.get("sel"), str) or not c["sel"].strip():
|
||||
errs.append(f"callouts[{i}] 는 {{n, sel}}")
|
||||
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:
|
||||
@@ -290,8 +302,36 @@ 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)
|
||||
x1 = max(b["x"] + b["width"] for b in boxes)
|
||||
y1 = max(b["y"] + b["height"] for b in boxes)
|
||||
return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
|
||||
|
||||
|
||||
def account_cred(account: str) -> dict[str, str]:
|
||||
"""장면 account → {email, password} · 파일 없음 · 칸 없음이면 SceneError(값은 안 찍음)."""
|
||||
path = CRED if account == "sys" else ACCOUNTS_FILE
|
||||
if not path.is_file():
|
||||
raise SceneError(f"계정 파일 없음: tmp/{path.name}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
cred = data if account == "sys" else data.get(ACCOUNT_KEYS[account])
|
||||
if not isinstance(cred, dict) or not cred.get("email") or not cred.get("password"):
|
||||
raise SceneError(f"계정 '{account}' 의 email · password 없음")
|
||||
return cred
|
||||
|
||||
|
||||
def callout_boxes(box: dict[str, float], pos: str, vw: int, vh: int) -> tuple[dict, dict]:
|
||||
"""요소 상자(CSS px) → (노란 테두리 상자, 번호 원 상자) · 원은 바깥 모서리 · 화면 안으로 당김."""
|
||||
"""요소 상자(CSS px) → (노란 테두리 상자, 번호 원 상자) · 원은 바깥 모서리 · 화면 밖이면 안으로 밈."""
|
||||
ring = {
|
||||
"x": box["x"] - RING_PAD,
|
||||
"y": box["y"] - RING_PAD,
|
||||
@@ -447,7 +487,11 @@ def shoot_scene(
|
||||
page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS))
|
||||
|
||||
# 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음)
|
||||
targets = [(c["sel"], c.get("frame")) for c in scene.get("callouts") or []]
|
||||
targets = [
|
||||
(sel, c.get("frame"))
|
||||
for c in scene.get("callouts") or []
|
||||
for sel in callout_sels(c["sel"])
|
||||
]
|
||||
if scene.get("clip"):
|
||||
targets.insert(0, (scene["clip"], None))
|
||||
for sel, frame in targets:
|
||||
@@ -457,8 +501,9 @@ def shoot_scene(
|
||||
pass # 안 보이면 아래 _box 가 까닭을 냄
|
||||
items, boxes = [], []
|
||||
for c in scene.get("callouts") or []:
|
||||
ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"),
|
||||
c.get("pos", "tl"), vw, vh) # fmt: skip
|
||||
frame = c.get("frame")
|
||||
els = [_box(page, sel, frame, f"번호 {c['n']}") for sel in callout_sels(c["sel"])]
|
||||
ring, dot = callout_boxes(union_box(els), c.get("pos", "tl"), vw, vh)
|
||||
items.append({"n": c["n"], "ring": ring, "dot": dot})
|
||||
boxes += [ring, dot]
|
||||
clip = None
|
||||
@@ -475,14 +520,14 @@ def shoot_scene(
|
||||
page.close()
|
||||
|
||||
|
||||
def login(context: Any, api: str) -> None:
|
||||
cred = json.loads(CRED.read_text(encoding="utf-8"))
|
||||
def login(context: Any, api: str, account: str = "sys") -> None:
|
||||
cred = account_cred(account)
|
||||
res = context.request.post(
|
||||
api + "/api/auth/login/request",
|
||||
data={"email": cred["email"], "password": cred["password"]},
|
||||
)
|
||||
if not res.ok:
|
||||
raise SceneError(f"로그인 실패 HTTP {res.status}")
|
||||
raise SceneError(f"로그인 실패({account}) HTTP {res.status}")
|
||||
if res.json().get("status") == "otp_required":
|
||||
raise SceneError("로그인에 메일 인증 필요 — 개발 서버(AUTH_OTP_DISABLED)로")
|
||||
|
||||
@@ -505,13 +550,20 @@ def capture(scenes: list[dict[str, Any]], out: Path, args: argparse.Namespace) -
|
||||
last = e
|
||||
if browser is None:
|
||||
raise SceneError(f"브라우저(Edge · Chrome)를 못 띄움: {str(last).splitlines()[0]}")
|
||||
context = browser.new_context(
|
||||
viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
|
||||
device_scale_factor=SCALE,
|
||||
locale="ko-KR",
|
||||
color_scheme="light",
|
||||
)
|
||||
login(context, args.api)
|
||||
contexts: dict[str, Any] = {} # 계정별 로그인한 컨텍스트 — 처음 쓸 때 만듦
|
||||
|
||||
def context_for(account: str) -> Any:
|
||||
if account not in contexts:
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
|
||||
device_scale_factor=SCALE,
|
||||
locale="ko-KR",
|
||||
color_scheme="light",
|
||||
)
|
||||
login(ctx, args.api, account)
|
||||
contexts[account] = ctx
|
||||
return contexts[account]
|
||||
|
||||
for sc in scenes:
|
||||
row = {"name": sc["name"], "size": "", "kb": "", "sec": "", "why": ""}
|
||||
t0 = time.perf_counter()
|
||||
@@ -520,7 +572,8 @@ def capture(scenes: list[dict[str, Any]], out: Path, args: argparse.Namespace) -
|
||||
else:
|
||||
try:
|
||||
pid = resolve_project(sc.get("project", "none"), args.project_id, projects)
|
||||
png, blocked = shoot_scene(context, args.vite, sc, pid)
|
||||
ctx = context_for(sc.get("account", "sys"))
|
||||
png, blocked = shoot_scene(ctx, args.vite, sc, pid)
|
||||
w, h, kb = save_webp(png, out / f"{sc['name']}.webp")
|
||||
row.update(size=f"{w}×{h}", kb=kb)
|
||||
if blocked:
|
||||
@@ -566,7 +619,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
skip = manual_names(out)
|
||||
for s in scenes:
|
||||
mark = " (건너뜀 _manual)" if s["name"] in skip and not args.force else ""
|
||||
print(f"{s['name']} {s['route']} {s.get('project', 'none')}{mark}")
|
||||
acct = s.get("account", "sys")
|
||||
print(f"{s['name']} {s['route']} {s.get('project', 'none')} {acct}{mark}")
|
||||
return 0
|
||||
rows = capture(scenes, out, args)
|
||||
except SceneError as e:
|
||||
|
||||
@@ -219,11 +219,12 @@ class _FakePool:
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, *_):
|
||||
return None
|
||||
async def execute(self, sql, *_):
|
||||
self.sql = sql
|
||||
|
||||
async def fetchone(self):
|
||||
return pool.row
|
||||
# 예정노선 지정(plan_route_assignees) 없음 — 회사 대조만 시험.
|
||||
return None if "plan_route_assignees" in self.sql else pool.row
|
||||
|
||||
class _Connection:
|
||||
async def __aenter__(self):
|
||||
|
||||
@@ -189,3 +189,73 @@ def test_callout_and_clip_boxes():
|
||||
clip = G.union_clip([ring, dot], 16, 1600, 900)
|
||||
assert clip["x"] <= dot["x"] - 16 + 0.01 and clip["width"] > ring["w"]
|
||||
assert G.union_clip([{"x": -50, "y": 0, "w": 2000, "h": 10}], 16, 1600, 900)["width"] == 1600
|
||||
|
||||
|
||||
@pytest.mark.parametrize("acct", ["sys", "company_admin", "user"])
|
||||
def test_account_ok(acct):
|
||||
assert G.check_scene(_scene(account=acct), "B05") == []
|
||||
|
||||
|
||||
def test_account_bad():
|
||||
assert G.check_scene(_scene(account="admin"), "B05")
|
||||
|
||||
|
||||
def test_callout_sel_list():
|
||||
ok = _scene(callouts=[{"n": 1, "sel": [".a", ".b"]}, {"n": 2, "sel": ".c"}])
|
||||
assert G.check_scene(ok, "B05") == []
|
||||
for bad in ([], [""], [".a", 3], 5, None):
|
||||
assert G.check_scene(_scene(callouts=[{"n": 1, "sel": bad}]), "B05")
|
||||
|
||||
|
||||
def test_union_box_and_ring():
|
||||
u = G.union_box(
|
||||
[
|
||||
{"x": 10, "y": 20, "width": 30, "height": 10},
|
||||
{"x": 100, "y": 5, "width": 20, "height": 10},
|
||||
]
|
||||
)
|
||||
assert u == {"x": 10, "y": 5, "width": 110, "height": 25}
|
||||
ring, _ = G.callout_boxes(u, "tl", 1600, 900)
|
||||
assert ring == {"x": 6, "y": 1, "w": 118, "h": 33}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pos", ["tl", "tr", "bl", "br"])
|
||||
def test_dot_pushed_inside_only_when_overflow(pos):
|
||||
vw, vh = 800, 600
|
||||
# 화면 모서리에 붙은 요소 → 원이 밖으로 나갈 자리 = 안으로
|
||||
for box in (
|
||||
{"x": 0, "y": 0, "width": 800, "height": 600},
|
||||
{"x": 790, "y": 590, "width": 10, "height": 10},
|
||||
):
|
||||
_, dot = G.callout_boxes(box, pos, vw, vh)
|
||||
assert 0 <= dot["x"] and dot["x"] + dot["w"] <= vw
|
||||
assert 0 <= dot["y"] and dot["y"] + dot["h"] <= vh
|
||||
# 안쪽 요소 → 원 자리 그대로(바깥 모서리 20px 걸침)
|
||||
ring, dot = G.callout_boxes({"x": 300, "y": 300, "width": 100, "height": 50}, pos, vw, vh)
|
||||
assert dot["x"] == (ring["x"] - 20 if pos in ("tl", "bl") else ring["x"] + ring["w"] - 8)
|
||||
assert dot["y"] == (ring["y"] - 20 if pos in ("tl", "tr") else ring["y"] + ring["h"] - 8)
|
||||
|
||||
|
||||
def test_account_cred(tmp_path, monkeypatch):
|
||||
f = tmp_path / "acc.json"
|
||||
f.write_text(
|
||||
json.dumps({"회사관리자": {"email": "a@b", "password": "x"}, "일반": {"email": "c@d"}})
|
||||
)
|
||||
sysf = tmp_path / "sys.json"
|
||||
sysf.write_text(json.dumps({"email": "s@s", "password": "y"}))
|
||||
monkeypatch.setattr(G, "ACCOUNTS_FILE", f)
|
||||
monkeypatch.setattr(G, "CRED", sysf)
|
||||
assert G.account_cred("company_admin")["email"] == "a@b"
|
||||
assert G.account_cred("sys")["email"] == "s@s"
|
||||
with pytest.raises(G.SceneError) as e:
|
||||
G.account_cred("user") # password 없음
|
||||
assert "x" not in str(e.value).replace("password", "")
|
||||
monkeypatch.setattr(G, "ACCOUNTS_FILE", tmp_path / "none.json")
|
||||
with pytest.raises(G.SceneError):
|
||||
G.account_cred("company_admin")
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user