"""기기 대응 자동 점검(요구 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 && !reachable(el, 'x')) out.too_wide++; } out.pointer = matchMedia('(pointer: coarse)').matches ? 'coarse' : 'fine'; out.rendered = !!(outlet && outlet.childElementCount); out.outlet_top = outlet ? Math.round(outlet.getBoundingClientRect().top) : null; // 본문 위 끝(위 줄 걷힘 = 0 · 6-3) return out; } """ # ☰ 상자와 겹치는 보이는 글 · 단추(7-3) — 왼쪽 위에 뜬 ☰ 아래 깔린 것만(맨 위에 보이는 것 · 같은 출처 iframe 안 포함) BURGER_JS = r""" () => { const b = document.querySelector('.app-burger'); if (!b || !b.getClientRects().length) return []; const B = b.getBoundingClientRect(), hits = []; const scan = (doc, ox, oy) => { const L = B.left - ox, T = B.top - oy, R = B.right - ox, D = B.bottom - oy; const cands = new Set(doc.querySelectorAll('button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=tab]')); const w = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) if (n.nodeValue.trim()) cands.add(n); for (const c of cands) { const el = c.nodeType === 3 ? c.parentElement : c; if (!el || el.closest('.app-header, .ui-toast')) continue; if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; let r = el.getBoundingClientRect(); if (c.nodeType === 3) { const g = doc.createRange(); g.selectNodeContents(c); r = g.getBoundingClientRect(); } const l = Math.max(r.left, L), t = Math.max(r.top, T), rr = Math.min(r.right, R), bb = Math.min(r.bottom, D); if (r.width < 1 || r.height < 1 || rr - l < 1 || bb - t < 1) continue; const top = doc.elementsFromPoint((l + rr) / 2, (t + bb) / 2).find((e) => !e.closest('.app-header')); if (top && top !== el && !el.contains(top) && !top.contains(el)) continue; hits.push(`${el.tagName.toLowerCase()}:${(c.nodeType === 3 ? c.nodeValue : el.textContent).trim().slice(0, 16)}`); } for (const f of doc.querySelectorAll('iframe')) { const fr = f.getBoundingClientRect(); if (fr.right < L || fr.left > R || fr.bottom < T || fr.top > D) continue; try { if (f.contentDocument?.body) scan(f.contentDocument, ox + fr.left + f.clientLeft, oy + fr.top + f.clientTop); } catch (e) {} } }; scan(document, 0, 0); return hits; } """ # 데스크탑 배치 서명 — 화면 안에 보이는 요소 사각형(정수) · 순서대로. # 잡음 빼기 = 움직이는 중(CSS 애니메이션 · 로딩 원)인 요소와 그 안 · 화면 밖(스크롤 아래 1px 흔들림) LAYOUT_JS = r""" () => { const rows = []; const moving = new Set(document.getAnimations().map((a) => a.effect && a.effect.target).filter(Boolean)); const isMoving = (el) => { for (let p = el; p; p = p.parentElement) if (moving.has(p)) return true; return false; }; for (const el of document.body.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue; if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) continue; if (isMoving(el)) 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 try: # 불러오는 중 표시(돌이 · 진행 원)가 사라질 때까지 — 반쯤 그린 화면을 찍지 않게 page.wait_for_function( "() => ![...document.querySelectorAll('.ui-loading, .ui-progress-circle')]" ".some((e) => e.getClientRects().length)", timeout=45000, ) 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) row["burger_hits"] = page.evaluate(BURGER_JS) 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']} " f"top={row['outlet_top']} burger={len(row['burger_hits'])}", 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 _boxes(rows): """HTML 상자 · svg 틀만 — svg 안 선 · 글(path · g · text 등 소문자 태그)은 자료 · 날짜로 바뀌는 그림이라 뺌.""" return [r for r in rows if r.split("|")[0].isupper() or r.startswith("svg|")] def layout_diff(route: str, a="before", b="after") -> int: """데스크탑 배치 서명 차이 줄 수(0 = 같음).""" ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text())) rb = _boxes(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 _rect(row): tag, xywh = row.split("|") return tag, tuple(int(v) for v in xywh.split(",")) def bm_moved(route: str, a="before", b="after") -> list: """로그인 뒤 쪽(B · M) 데스크탑 — 위 줄(64)을 걷어 세로는 올라가도 가로 자리(태그 · left · width)는 같아야 함(7-3). 원래 상자 가운데 지금 같은 가로 자리가 없는 것. 빼는 것 = 위 줄 · ☰ 띠(위 64 + 60 안 · 들여쓴 첫 줄) · 없어진 떠 있는 진행 판(ASIDE) 안 · 화면 전체 상자.""" ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text())) rb = _boxes(json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text())) import collections left = collections.Counter((t, r[0], r[2]) for t, r in map(_rect, rb)) missing = [] for row in ra: t, r = _rect(row) if left[(t, r[0], r[2])] > 0: left[(t, r[0], r[2])] -= 1 else: missing.append((t, r)) gone = [r for t, r in missing if t == "ASIDE"] def inside(r, g): return ( g[0] <= r[0] and g[1] <= r[1] and r[0] + r[2] <= g[0] + g[2] and r[1] + r[3] <= g[1] + g[3] ) return [ f"{t}|{r}" for t, r in missing if r[1] >= 64 + 60 and r[2] < SIZES[DESKTOP]["viewport"]["width"] and not any(inside(r, g) for g in gone) ] 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) # 로그인 뒤 쪽 = ☰ 왼쪽 위 · 그 상자와 겹치는 글 · 단추 0 · 본문 위 끝 0(7-3) if route[0] in "bm": assert not row["burger_hits"], (route, size, row["burger_hits"]) assert row["outlet_top"] == (40 if route == "b03-file-input" else 0), (route, size) if (ROOT / "tmp" / "desktop_before2").exists(): # 공개 쪽 = 배치 그대로 · 로그인 뒤 쪽 = 가로 자리 그대로 — 같은 원래 코드 두 번 재기(before · before2) 잡음 이하 def diff(r, b): return len(bm_moved(r, "before", b)) if r[0] in "bm" else layout_diff(r, "before", b) noise = {r: diff(r, "before2") for r in PAGES} over = [r for r in PAGES if diff(r, "after") > noise[r]] if over: # 불러오는 때에 따라 한 번 흔들림(B06 종단) — 그 쪽만 한 번 더 잼 run_audit("after_retry", pages=over, sizes={DESKTOP: SIZES[DESKTOP]}) over = {r: diff(r, "after_retry") for r in over if diff(r, "after_retry") > noise[r]} assert not over, over 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)