Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""기기 대응 자동 점검(요구 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;
|
|
}
|
|
"""
|
|
|
|
# 데스크탑 배치 서명 — 화면 안에 보이는 요소 사각형(정수) · 순서대로.
|
|
# 잡음 빼기 = 움직이는 중(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)
|
|
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)
|