test(device): 기기 대응 자동 점검 — 22쪽 × 네 크기(390 · 820 · 1180 터치 · 1440 마우스) 가로 넘침 · 화면 밖 · 가려짐 · 44px 미만 · 못 닿는 메뉴 셈 · 데스크탑 사진 + 배치 서명 대조 (기기대응 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user