designpaca/apps/site/tools/invariants.mjs
Yun Chan 2b7fa32b5b
All checks were successful
ci / build (push) Successful in 51s
release / release (push) Successful in 2m34s
feat: 한국어 우선 쇼케이스 정리
2026-08-26 18:14:41 +09:00

183 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.__textLines(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);