Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4

This commit is contained in:
2026-10-26 09:27:16 +09:00
3 changed files with 149 additions and 24 deletions
+75 -21
View File
@@ -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