Files
Aislo/resources/tester/test_device_audit.py
T

275 lines
12 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 && !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;
}
"""
# 데스크탑 배치 서명 — 화면 안에 보이는 요소 사각형(정수) · 순서대로.
# 잡음 빼기 = 움직이는 중(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 _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 _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_before2").exists():
# 차이 0 = 같은 원래 코드 두 번 재기(before · before2) 잡음 이하
noise = {r: layout_diff(r, "before", "before2") for r in PAGES}
over = [r for r in PAGES if layout_diff(r) > noise[r]]
if over: # 불러오는 때에 따라 한 번 흔들림(B06 종단) — 그 쪽만 한 번 더 잼
run_audit("after_retry", pages=over, sizes={DESKTOP: SIZES[DESKTOP]})
over = {
r: layout_diff(r, "before", "after_retry")
for r in over
if layout_diff(r, "before", "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)