- 핵심 규칙 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 실패 시 배포 금지 체인
This commit is contained in:
parent
78ddc8b61a
commit
72337b7ee0
226 changed files with 18212 additions and 380 deletions
81
apps/site/tools/visual.mjs
Normal file
81
apps/site/tools/visual.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// L3 — 시각 회귀. 기준 화면과 pixelmatch 비교.
|
||||
// 사용: node tools/visual.mjs [--update-baseline]
|
||||
// 규칙: diff 픽셀 비율 ≥ 0.1% → 실패(안티앨리어싱 잡음은 무시).
|
||||
// 기준 갱신은 검증된 배포 후에만 --update-baseline 으로.
|
||||
import puppeteer from "puppeteer-core";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { PNG } from "pngjs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import fs from "node:fs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(here, "../public/work");
|
||||
const SHOTS = path.join(here, "__shots__");
|
||||
const BASE = path.join(SHOTS, "baseline");
|
||||
const CURR = path.join(SHOTS, "current");
|
||||
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
|
||||
const UPDATE = process.argv.includes("--update-baseline");
|
||||
|
||||
const APPS = {
|
||||
"gaon-lms": ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"],
|
||||
"dure-enrollment": ["catalog", "starred", "enrollments", "credits", "archive", "notices"],
|
||||
};
|
||||
const WIDTHS = [390, 1440];
|
||||
|
||||
fs.mkdirSync(BASE, { recursive: true });
|
||||
fs.mkdirSync(CURR, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
|
||||
const shots = [];
|
||||
for (const [app, views] of Object.entries(APPS)) {
|
||||
for (const w of WIDTHS) {
|
||||
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
|
||||
await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
for (const v of views) {
|
||||
await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const name = `${app}-${v}-${w}.png`;
|
||||
const file = path.join(CURR, name);
|
||||
await page.screenshot({ path: file });
|
||||
shots.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
let fails = 0;
|
||||
const rows = [];
|
||||
for (const name of shots) {
|
||||
const cur = PNG.sync.read(fs.readFileSync(path.join(CURR, name)));
|
||||
const baseFile = path.join(BASE, name);
|
||||
if (UPDATE || !fs.existsSync(baseFile)) {
|
||||
fs.writeFileSync(baseFile, PNG.sync.write(cur));
|
||||
rows.push({ name, status: UPDATE ? "갱신" : "신규기준", diff: "—" });
|
||||
continue;
|
||||
}
|
||||
const base = PNG.sync.read(fs.readFileSync(baseFile));
|
||||
if (base.width !== cur.width || base.height !== cur.height) {
|
||||
rows.push({ name, status: "FAIL", diff: "크기 불일치" });
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
const diff = new PNG({ width: base.width, height: base.height });
|
||||
const n = pixelmatch(base.data, cur.data, diff.data, base.width, base.height, { threshold: 0.1 });
|
||||
const ratio = (n / (base.width * base.height)) * 100;
|
||||
const bad = ratio >= 0.1;
|
||||
if (bad) {
|
||||
fails++;
|
||||
fs.writeFileSync(path.join(SHOTS, `diff-${name}`), PNG.sync.write(diff));
|
||||
}
|
||||
rows.push({ name, status: bad ? "FAIL" : "PASS", diff: ratio.toFixed(3) + "%" });
|
||||
}
|
||||
|
||||
for (const r of rows) console.log(`${r.status.padEnd(4)} ${r.name} ${r.diff}`);
|
||||
console.log(`\nVISUAL: ${fails ? `${fails} FAIL (기준 대비 회귀)` : UPDATE ? "기준 갱신 완료" : "ALL MATCH"}`);
|
||||
process.exit(fails ? 1 : 0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue