diff --git a/resources/tester/test_device_audit.py b/resources/tester/test_device_audit.py new file mode 100644 index 000000000..792906a81 --- /dev/null +++ b/resources/tester/test_device_audit.py @@ -0,0 +1,246 @@ +"""기기 대응 자동 점검(요구 193~195 · PLAN_기기대응 1-1 · 5-1). + +sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를 셈: +가로 넘침 · 화면 밖(밀어도 못 닿음) · 가려진 단추 · 44px 미만 누를 자리 · 닿을 수 없는 위 메뉴. +데스크탑(1440×900 마우스)은 사진 + 배치 서명(보이는 요소 사각형)을 남겨 전후 대조. + +실행: + python resources/tester/test_device_audit.py before → tmp/device_audit_before.json · tmp/desktop_before/ + python resources/tester/test_device_audit.py after → tmp/device_audit_after.json · tmp/desktop_after/ + pytest — 서버가 떠 있으면 한 바퀴(after) 돌려 페이지가 모두 열리는지 · 데스크탑 배치가 before 와 같은지 봄. +로그인 = tmp/aislo_cred.json(값은 출력하지 않음) · 프로젝트 = 검증_sub1. +""" + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BASE = "http://localhost:5174" +PROJECT_NAME = "검증_sub1" +PAGES = [ + "a01-home", + "a02-prog-detail", + "a03-comp-detail", + "a04-news-history", + "a05-edu-detail", + "a06-login", + "a07-register", + "a08-support", + "a09-security", + "b01-account", + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + "b10-payment", + "b11-status", + "m01-master-data", + "m02-master-template", +] +SIZES = { + "phone_390x844_touch": {"viewport": {"width": 390, "height": 844}, "touch": True}, + "tablet_820x1180_touch": {"viewport": {"width": 820, "height": 1180}, "touch": True}, + "tablet_1180x820_touch": {"viewport": {"width": 1180, "height": 820}, "touch": True}, + "desktop_1440x900_mouse": {"viewport": {"width": 1440, "height": 900}, "touch": False}, +} +DESKTOP = "desktop_1440x900_mouse" + +# 페이지 안에서 셈 — 숫자만 돌려줌(값 · 글은 안 담음) +AUDIT_JS = r""" +() => { + const vw = innerWidth, vh = innerHeight; + const SEL = 'button, a[href], input:not([type=hidden]), select, textarea, summary, ' + + '[role=button], [role=menuitem], [role=tab], [role=checkbox], [tabindex]:not([tabindex="-1"])'; + const vis = (el) => { + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return null; + if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null; + return r; + }; + const scrollable = (el, axis) => { + const cs = getComputedStyle(el); + const ov = axis === 'x' ? cs.overflowX : cs.overflowY; + if (!/(auto|scroll)/.test(ov)) return false; + return axis === 'x' ? el.scrollWidth > el.clientWidth + 1 : el.scrollHeight > el.clientHeight + 1; + }; + const se = document.scrollingElement; + const rootCs = getComputedStyle(document.documentElement), bodyCs = getComputedStyle(document.body); + const docScroll = { + x: se.scrollWidth > vw + 1 && !/hidden|clip/.test(rootCs.overflowX + bodyCs.overflowX), + y: se.scrollHeight > vh + 1 && !/hidden|clip/.test(rootCs.overflowY + bodyCs.overflowY), + }; + const reachable = (el, axis) => { + for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { + if (scrollable(p, axis)) return true; + if (getComputedStyle(p).position === 'fixed') return false; + } + return docScroll[axis]; + }; + const out = { hscroll_px: Math.max(0, se.scrollWidth - vw), offscreen: 0, covered: 0, small: 0, + targets: 0, menu_unreachable: 0, too_wide: 0, samples: { offscreen: [], covered: [], small: [] } }; + const name = (el) => { + const t = (el.getAttribute('aria-label') || el.title || el.textContent || el.placeholder || '').trim().slice(0, 24); + return `${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(/\s+/)[0] : ''}:${t}`; + }; + const push = (k, el) => { if (out.samples[k].length < 8) out.samples[k].push(name(el)); }; + for (const el of document.querySelectorAll(SEL)) { + if (el.closest('[aria-hidden=true], [inert]')) continue; + const r = vis(el); + if (!r) continue; + out.targets++; + const offX = r.right > vw + 1 || r.left < -1, offY = r.bottom > vh + 1 || r.top < -1; + if ((offX && !reachable(el, 'x')) || (offY && !reachable(el, 'y'))) { out.offscreen++; push('offscreen', el); continue; } + if (offX || offY) continue; + const cx = Math.min(vw - 1, Math.max(0, r.left + r.width / 2)), cy = Math.min(vh - 1, Math.max(0, r.top + r.height / 2)); + const hit = document.elementFromPoint(cx, cy); + if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) + && !(el.tagName === 'INPUT' && hit.tagName === 'LABEL')) { out.covered++; push('covered', el); } + if (Math.min(r.width, r.height) < 44) { out.small++; push('small', el); } + } + // 위 메뉴 — 숨은 항목 수(☰ 이 보이면 닿음) + const nav = document.querySelector('.app-nav'); + const burger = document.querySelector('.app-burger'); + if (nav && !(burger && vis(burger))) { + for (const b of nav.querySelectorAll('button')) if (!vis(b)) out.menu_unreachable++; + } + // 본문 안 화면보다 넓은 상자(고정 폭) + const outlet = document.getElementById('app-outlet'); + if (outlet) for (const el of outlet.querySelectorAll('*')) { + if (el.offsetWidth > vw + 1 && !scrollable(el.parentElement || el, 'x')) out.too_wide++; + } + out.pointer = matchMedia('(pointer: coarse)').matches ? 'coarse' : 'fine'; + out.rendered = !!(outlet && outlet.childElementCount); + return out; +} +""" + +# 데스크탑 배치 서명 — 보이는 요소 사각형(정수) · 순서대로 +LAYOUT_JS = r""" +() => { + const rows = []; + for (const el of document.body.querySelectorAll('*')) { + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) continue; + if (el.checkVisibility && !el.checkVisibility({ visibilityProperty: true })) continue; + rows.push(`${el.tagName}|${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`); + } + return rows; +} +""" + + +def _project_id(ctx): + projects = ctx.request.get(f"{BASE}/api/dashboard/user/projects").json()["projects"] + return next(p["id"] for p in projects if p.get("name") == PROJECT_NAME) + + +def _login(ctx, cred): + for _ in range(3): # 서버가 바쁠 때 한 번씩 30초 넘김 + try: + r = ctx.request.post(f"{BASE}/api/auth/login/request", data=cred, timeout=60000) + break + except Exception: + continue + assert r.ok, f"로그인 실패 {r.status}" + + +def _open(page, route): + page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") + try: + page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + page.wait_for_timeout(1500) + + +def run_audit(tag: str, pages=PAGES, sizes=SIZES) -> dict: + from playwright.sync_api import sync_playwright + + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = ROOT / "tmp" / f"desktop_{tag}" + shots.mkdir(parents=True, exist_ok=True) + result = {"base": BASE, "sizes": list(sizes), "pages": {}} + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + for size, spec in sizes.items(): + ctx = browser.new_context( + viewport=spec["viewport"], + has_touch=spec["touch"], + is_mobile=spec["touch"], + locale="ko-KR", + ) + _login(ctx, cred) + pid = _project_id(ctx) + ctx.add_init_script( + f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})" + ) + page = ctx.new_page() + errors = [] + page.on("pageerror", lambda e: errors.append(str(e)[:120])) + for route in pages: + errors.clear() + _open(page, route) + row = page.evaluate(AUDIT_JS) + row["errors"] = list(errors) + if size == DESKTOP: + page.screenshot(path=str(shots / f"{route}.png")) + (shots / f"{route}.layout.json").write_text( + json.dumps(page.evaluate(LAYOUT_JS)), encoding="utf-8" + ) + result["pages"].setdefault(route, {})[size] = row + print( + f"{route:22} {size:24} hs={row['hscroll_px']:4} off={row['offscreen']:3} " + f"cov={row['covered']:3} small={row['small']:3} menu={row['menu_unreachable']}", + flush=True, + ) + ctx.close() + browser.close() + out = ROOT / "tmp" / f"device_audit_{tag}.json" + out.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8") + return result + + +def layout_diff(route: str, a="before", b="after") -> int: + """데스크탑 배치 서명 차이 줄 수(0 = 같음).""" + ra = json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text()) + rb = json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text()) + import difflib + + return sum( + 1 for line in difflib.ndiff(ra, rb) if line.startswith("+ ") or line.startswith("- ") + ) + + +def _server_up() -> bool: + import urllib.request + + try: + urllib.request.urlopen(BASE, timeout=2) + return True + except Exception: + return False + + +@pytest.mark.skipif(not _server_up(), reason="sub1 화면 서버(5174) 꺼짐") +def test_device_audit_all_pages_render_and_desktop_unchanged(): + result = run_audit("after") + for route, sizes in result["pages"].items(): + for size, row in sizes.items(): + assert row["rendered"], f"{route} {size} 빈 화면" + assert row["pointer"] == ("fine" if size == DESKTOP else "coarse"), (route, size) + if (ROOT / "tmp" / "desktop_before").exists(): + diffs = {r: layout_diff(r) for r in PAGES} + assert not any(diffs.values()), diffs + + +if __name__ == "__main__": + tag = sys.argv[1] if len(sys.argv) > 1 else "after" + only = sys.argv[2].split(",") if len(sys.argv) > 2 else PAGES + run_audit(tag, pages=only)