designpaca/apps/site/tools/invariants.mjs
Yun Chan 72337b7ee0
Some checks failed
ci / build (push) Failing after 5s
feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
- 핵심 규칙 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 실패 시 배포 금지 체인
2026-08-22 21:22:05 +09:00

182 lines
8.7 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.__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);