Some checks failed
ci / build (push) Failing after 5s
- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다 - 5단계 기계 검사 4번째: design-gate 실행 의무화 - references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙 - tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합) - 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0) feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18) - 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판 - LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png) - 시간표 자동배치 결함 수리(명시적 격자 좌표) - QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
182 lines
8.7 KiB
JavaScript
182 lines
8.7 KiB
JavaScript
// 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);
|