// L2 — 렌더링 불변식. file:// 로 앱을 열어 검사한다(서버 불필요). // 줄수(세로 쌓임·갸행) · 수축 탐지 · 대비 · axe 접근성 · 콘텐츠 정합 · 스케일×폭 행렬. import puppeteer from "puppeteer-core"; import path from "node:path"; import url from "node:url"; const here = path.dirname(url.fileURLToPath(import.meta.url)); const ROOT = path.resolve(here, "../public/work"); const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; const results = []; const fail = (name, detail = "") => results.push({ ok: false, name, detail }); const pass = (name, detail = "") => results.push({ ok: true, name, detail }); const GAON_VIEWS = ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"]; const DURE_VIEWS = ["catalog", "starred", "enrollments", "credits", "archive", "notices"]; const PAGE_UTILS = ` window.__lines = (el) => { if (!el) return -1; const range = document.createRange(); range.selectNodeContents(el); const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); const tops = rects.map((r) => r.top).sort((a, b) => a - b); const ls = []; for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); return ls.length; }; window.__textLines = (el) => { if (!el) return -1; const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim()); const target = tn || el; const range = document.createRange(); range.selectNodeContents(target); const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); const tops = rects.map((r) => r.top).sort((a, b) => a - b); const ls = []; for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); return ls.length; }; `; async function openApp(page, app) { await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" }); await page.evaluate(() => document.fonts.ready); await page.evaluate(PAGE_UTILS); } async function scanInvariants(page) { return page.evaluate(() => { const out = { stacked: [], contrast: [], h1: [] }; const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; }; const blend = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 }); const bgOf = (el) => { let node = el; const stack = []; while (node && node !== document.documentElement) { const c = rgb(getComputedStyle(node).backgroundColor); if (c && c.a >= 0.95) return c; if (c && c.a > 0) stack.push(c); node = node.parentElement; } let base = { r: 255, g: 255, b: 255, a: 1 }; for (let i = stack.length - 1; i >= 0; i--) base = blend(stack[i], base); return base; }; const h1 = document.querySelector(".view.is-on h2.v-h") || document.querySelector("h1"); if (h1 && window.__lines(h1) > 1) out.h1.push(h1.textContent.trim().slice(0, 16)); document.querySelectorAll("body *").forEach((el) => { if (!el.offsetParent) return; const txt = el.textContent.trim(); const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); if (!txt || !ownText) return; const cs = getComputedStyle(el); if (txt.length <= 14 && !el.querySelector("*") && window.__textLines(el) >= 3) { out.stacked.push(txt.slice(0, 12) + " (" + window.__textLines(el) + "줄)"); } const fg = rgb(cs.color); if (fg) { const bg = bgOf(el); const fgb = blend(fg, bg); const l1 = lum(fgb), l2 = lum(bg); const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2); const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400; const need = size >= 24 || (size >= 18.66 && w >= 600) ? 3 : 4.5; if (ratio < need) { const key = cs.color + Math.round(size); if (!out.contrast.some((c) => c.key === key)) { out.contrast.push({ key, txt: txt.slice(0, 12), ratio, need }); } } } }); return out; }); } async function runAxe(page) { const axePath = path.resolve(here, "../node_modules/axe-core/axe.min.js"); await page.addScriptTag({ path: axePath }); const res = await page.evaluate(async () => { const r = await window.axe.run(document, { resultTypes: ["violations"] }); return r.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })); }); return res; } async function contentConsistency(page) { return page.evaluate(() => { const out = []; if (window.GAON && document.getElementById("rep-stats")) { const stats = GAON.rosterStats(STUDENTS); const txt = document.getElementById("rep-stats").textContent; if (!txt.includes(stats.active + "명")) out.push("리포트 재학생 " + stats.active + "명 표시 안 됨"); if (!txt.includes(stats.avgAtt + "%")) out.push("리포트 평균출석 " + stats.avgAtt + "% 표시 안 됨"); if (!txt.includes(stats.hwRate + "%")) out.push("리포트 제출률 " + stats.hwRate + "% 표시 안 됨"); } if (window.DURE && document.getElementById("en-credits")) { const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id)); const want = DURE.credits(list); const got = document.getElementById("en-credits").textContent.trim(); if (got !== String(want)) out.push("신청 학점 표시 " + got + " ≠ 재계산 " + want); } return out; }); } const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); const page = await browser.newPage(); await page.setCacheEnabled(false); for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_VIEWS]]) { await page.setViewport({ width: 1440, height: 900 }); await openApp(page, app); for (const v of views) { await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v); await new Promise((r) => setTimeout(r, 80)); const inv = await scanInvariants(page); if (inv.h1.length) fail(`${app}/${v} 뷰 제목 여러 줄`, inv.h1.join(", ")); if (inv.stacked.length) fail(`${app}/${v} 세로 쌓임(수축)`, inv.stacked.slice(0, 4).join(", ")); if (inv.contrast.length) fail(`${app}/${v} 대비 미달`, inv.contrast.slice(0, 3).map((c) => `"${c.txt}" ${c.ratio}:1`).join(", ")); } pass(`${app} 불변식(전 뷰)`, "제목·수축·대비"); await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), views[0]); const violations = await runAxe(page); const serious = violations.filter((v) => v.impact === "serious" || v.impact === "critical"); if (serious.length) fail(`${app} axe 심각 위반`, serious.map((v) => `${v.id}×${v.nodes}`).join(", ")); else pass(`${app} axe`, violations.length ? `minor ${violations.length}종 무시` : "위반 0"); const cons = await contentConsistency(page); if (cons.length) fail(`${app} 콘텐츠 정합`, cons.join(" / ")); else pass(`${app} 콘텐츠 정합`); let matrixBad = []; for (const w of [320, 375, 390]) { for (const scale of [1.0, 1.3]) { await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); await openApp(page, app); await page.addStyleTag({ content: `html { font-size: ${16 * scale}px !important; }` }); const lines = await page.evaluate("window.__lines(document.querySelector('.view.is-on h2.v-h') || document.querySelector('h1'))"); if (lines > 1) matrixBad.push(`${w}@${scale}x:${lines}줄`); } } if (matrixBad.length) fail(`${app} 스케일×폭 제목`, matrixBad.join(", ")); else pass(`${app} 스케일×폭 제목 행렬`); await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(page, app); const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))"); if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄"); else pass(`${app} 브랜드 1줄`); } await browser.close(); const totalFail = results.filter((r) => !r.ok).length; for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? ` — ${r.detail}` : "")); console.log(totalFail ? `\nINVARIANTS: ${totalFail} FAIL` : "\nINVARIANTS ALL PASS"); process.exit(totalFail ? 1 : 0);