"""539-1 가이드 캡처 — 화면 없이 도는 부분(장면 목록 읽기 · 검사 · 이름 규칙 · _manual 건너뜀 · WebP).""" import io import json import sys import tempfile from pathlib import Path import pytest from PIL import Image sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "A05_EduDetail")) import A05_EduDetail_Guide_Capture as G # noqa: E402 def _scene(**kw): base = {"name": "B05_01_plan", "route": "#/b05-profile", "project": "road"} return {**base, **kw} def _folder(scenes, manual=None) -> Path: d = Path(tempfile.mkdtemp()) (d / "_scenes.json").write_text(json.dumps(scenes, ensure_ascii=False), encoding="utf-8") if manual is not None: (d / "_manual.json").write_text(json.dumps(manual), encoding="utf-8") return d def test_good_scene_passes(): sc = _scene( before=[ {"click": ".card"}, {"wait": ".modal"}, {"wait_ms": 300}, {"hover": ".tip"}, {"select": "#kind", "value": "plan"}, {"scroll": ".far"}, ], clip=".modal", callouts=[{"n": 1, "sel": ".a"}, {"n": 2, "sel": ".b", "pos": "br"}], after=[{"press": "Escape"}], ) assert G.check_scene(sc, "B05") == [] assert G.load_scenes(_folder([sc]), "B05")[0]["name"] == "B05_01_plan" @pytest.mark.parametrize( "name", ["B05_1_plan", "b05_01_plan", "B05_01_Plan", "B05_01_평면", "B05-01-plan", "B06_01_plan", None], ) def test_name_rule(name): assert G.check_scene(_scene(name=name), "B05") @pytest.mark.parametrize( "bad", [ {"route": "b05-profile"}, {"project": "mine"}, {"viewport": [100, 900]}, {"clip": ""}, {"callouts": [{"n": 1, "sel": ".a"}, {"n": 1, "sel": ".b"}]}, {"callouts": [{"n": 0, "sel": ".a"}]}, {"callouts": [{"n": 1, "sel": ".a", "pos": "mid"}]}, {"before": [{"click": ".a", "wait": ".b"}]}, {"before": [{"fill": ".a"}]}, {"before": [{"select": ".a"}]}, {"before": [{"scroll": ""}]}, {"before": [{"jump": ".a"}]}, {"allow_post": ["projects"]}, {"extra": 1}, ], ) def test_bad_fields(bad): assert G.check_scene(_scene(**bad), "B05") @pytest.mark.parametrize( "sel", [ "button:has-text('저장')", "text=확정", ".reset-btn", "button:has-text('초기화')", "button:has-text('적용')", "button:has-text('메일 보내기')", "#save", "button:has-text('삭제')", ], ) def test_danger_click_rejected(sel): errs = G.check_scene(_scene(before=[{"click": sel}]), "B05") assert any("위험" in e for e in errs) def test_danger_eval_rejected(): errs = G.check_scene( _scene(before=[{"eval": "document.querySelector('.save').click()"}]), "B05" ) assert any("위험" in e for e in errs) @pytest.mark.parametrize( "sel", ["button:has-text('취소')", "text=닫기", ".cancel", "button:has-text('내보내기')"] ) def test_safe_click_passes(sel): assert G.check_scene(_scene(before=[{"click": sel}]), "B05") == [] def test_confirm_needs_cancel_after(): sc = _scene(before=[{"click": "button:has-text('저장')", "confirm": True}]) assert any("after" in e for e in G.check_scene(sc, "B05")) sc["after"] = [{"click": "button:has-text('취소')"}] assert G.check_scene(sc, "B05") == [] sc["after"] = [{"click": "button:has-text('확인')", "confirm": True}] assert G.check_scene(sc, "B05") def test_is_danger_runtime_text(): assert G.is_danger("저장") assert G.is_danger("추천 노선 적용") assert not G.is_danger("취소") assert not G.is_danger("엑셀 내보내기") assert not G.is_danger("") def test_load_reports_all_errors_and_duplicates(): d = _folder([_scene(), _scene(), _scene(name="B05_02_x", route="x")]) with pytest.raises(G.SceneError) as e: G.load_scenes(d, "B05") assert "이름 겹침: B05_01_plan" in str(e.value) and "route" in str(e.value) def test_load_missing_and_broken(): with pytest.raises(G.SceneError): G.load_scenes(Path(tempfile.mkdtemp()), "B05") d = Path(tempfile.mkdtemp()) (d / "_scenes.json").write_text("[{", encoding="utf-8") with pytest.raises(G.SceneError): G.load_scenes(d, "B05") @pytest.mark.parametrize( "manual", [["B05_01_plan.webp"], ["B05_01_plan"], {"B05_01_plan.webp": {"by": "admin"}}] ) def test_manual_names(manual): assert G.manual_names(_folder([_scene()], manual)) == {"B05_01_plan"} def test_manual_skipped_in_list(capsys): d = _folder([_scene(), _scene(name="B05_02_cut")], ["B05_01_plan.webp"]) assert G.main(["--page", "B05", "--out", str(d), "--list"]) == 0 out = capsys.readouterr().out.splitlines() assert "건너뜀" in out[0] and "건너뜀" not in out[1] def test_pick_scenes(): scenes = [_scene(), _scene(name="B05_02_cut")] assert [s["name"] for s in G.pick_scenes(scenes, ["B05_02"])] == ["B05_02_cut"] assert len(G.pick_scenes(scenes, None)) == 2 with pytest.raises(G.SceneError): G.pick_scenes(scenes, ["B05_09"]) def test_resolve_project(): assert G.resolve_project("none", "x", {}) is None assert G.resolve_project("road", None, {"road": "r1"}) == "r1" assert G.resolve_project("road", "cli", {"road": "r1"}) == "cli" with pytest.raises(G.SceneError): G.resolve_project("stab", None, {"road": "r1"}) def test_save_webp(): buf = io.BytesIO() Image.new("RGBA", (64, 32), (255, 212, 0, 255)).save(buf, "PNG") path = Path(tempfile.mkdtemp()) / "B05_01_plan.webp" w, h, kb = G.save_webp(buf.getvalue(), path) assert (w, h) == (64, 32) and kb > 0 with Image.open(path) as img: assert img.format == "WEBP" and img.mode == "RGB" def test_callout_and_clip_boxes(): ring, dot = G.callout_boxes({"x": 100, "y": 100, "width": 50, "height": 20}, "tl", 1600, 900) assert ring == {"x": 96, "y": 96, "w": 58, "h": 28} assert dot["x"] < ring["x"] and dot["y"] < ring["y"] # 바깥 왼쪽 위 _, edge = G.callout_boxes({"x": 0, "y": 0, "width": 50, "height": 20}, "tl", 1600, 900) assert edge["x"] >= 0 and edge["y"] >= 0 # 화면 밖이면 안으로 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