feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
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 실패 시 배포 금지 체인
This commit is contained in:
Yun Chan 2026-08-22 21:22:05 +09:00
parent 78ddc8b61a
commit 72337b7ee0
226 changed files with 18212 additions and 380 deletions

201
apps/site/tools/audit.mjs Normal file
View file

@ -0,0 +1,201 @@
// 확장된 두 앱 감사: 신규 뷰 전부 + 기존 기능 회귀 + 오버플로
import puppeteer from "puppeteer-core";
import path from "node:path";
const root = "D:/workspace/designpaca/apps/site";
const chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const results = [];
const pass = (name, ok, detail = "") =>
results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? `${detail}` : ""}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: ["--no-sandbox", "--disable-gpu", "--force-device-scale-factor=1"],
defaultViewport: { width: 1440, height: 900 },
});
const page = await browser.newPage();
page.on("pageerror", (e) => results.push(`FAIL pageerror — ${e.message}`));
const jclick = (sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) throw new Error("no " + s); el.click(); }, sel);
const over = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
const gotoFile = async (app) =>
page.goto(`file:///${path.join(root, "public/work", app, "index.html")}`, { waitUntil: "networkidle0" });
// ══════════ 가온 학적부 (8 뷰) ══════════
await gotoFile("gaon-lms");
for (const v of ["dashboard","students","attendance","grades","files","calendar","counsel","report","notice"]) {
await jclick(`[data-view="${v}"]`);
pass(`가온 뷰 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on")));
}
pass("가온 aria-current(공지)", await page.$eval('[data-view="notice"]', (el) => el.getAttribute("aria-current") === "page"));
// 사이드바 그룹 헤더 3개
pass("가온 사이드 그룹 3개", (await page.$$eval(".side-group", (n) => n.length)) === 3);
// 출석부: 셀 순환 + 주 전환 + 반 전환
await jclick('[data-view="attendance"]');
const cellBefore = await page.$eval(".att-btn", (b) => b.textContent);
await jclick(".att-btn");
const cellAfter = await page.$eval(".att-btn", (b) => b.textContent);
pass("가온 출석 순환 ○→△", cellBefore === "○" && cellAfter === "△");
await jclick("#att-prev");
pass("가온 주 전환(1주)", (await page.$eval("#att-week", (el) => el.textContent)) === "3월 1주");
await jclick("#att-next");
await page.evaluate(() => document.querySelector("#att-class [data-sub='코딩 입문']").click());
await sleep(150);
const codingRows = await page.$$eval("#att-table tbody tr", (rows) => rows.map((r) => r.textContent.slice(0, 12)));
pass("가온 반 전환(코딩 입문 3행, 휴학 포함)", codingRows.length === 3, codingRows.join("|"));
// 자료실: 필터 + 배부 토글
await jclick('[data-view="files"]');
await page.evaluate(() => document.querySelector('#file-chips [data-kind="PDF"]').click());
await sleep(150);
pass("가온 자료 필터 PDF", (await page.$$("#file-body tr")).length === 2);
const giveBefore = await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"));
await jclick("[data-file]");
pass("가온 배부 토글", (await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"))) !== giveBefore);
// 달력: 42칸 + 오늘 1 + 예정 7
await jclick('[data-view="calendar"]');
pass("가온 달력 42칸·오늘 1", (await page.$$("#cal-table td")).length === 42 && (await page.$$(".is-today")).length === 1);
pass("가온 다가오는 일정 7건", (await page.$$("#up-list li")).length === 7);
// 상담: 필터 + 카드→학생 모달
await jclick('[data-view="counsel"]');
await page.evaluate(() => document.querySelector('#co-chips [data-sub="국어 논술 A"]').click());
await sleep(150);
pass("가온 상담 필터(국어 2건)", (await page.$$(".counsel-card")).length === 2);
await jclick(".counsel-card");
pass("가온 상담 카드→학생 모달", await page.$eval("#stu-dialog", (d) => d.open));
await page.keyboard.press("Escape");
// 리포트: 4행 + 수학 행 계산값
await jclick('[data-view="report"]');
pass("가온 리포트 4행", (await page.$$("#report-table tbody tr")).length === 4);
const repMath = await page.$eval("#report-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " "));
pass("가온 리포트 수학 행 실계산", repMath.includes("3명") && repMath.includes("91%") && repMath.includes("96%") && repMath.includes("84.4점"));
// 회귀: 검색/칩/학생 모달/메모/채점
await jclick('[data-view="students"]');
await page.type("#q", "박");
await sleep(200);
pass("가온 검색 축소(회귀)", (await page.$$("#view-students [data-stu]")).length === 1);
await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); });
await jclick("#view-students [data-stu]");
await page.waitForSelector("#stu-dialog[open]");
await page.type("#note-input", "확정 회귀 검사");
await jclick("#note-form button[type=submit]");
await sleep(150);
pass("가온 메모 추가(회귀)", (await page.$$("#stu-note-list li")).length === 2);
await jclick("#stu-dialog .modal-close");
await jclick('[data-view="grades"]');
await page.evaluate(() => document.querySelector('[data-assign="a2"]').click());
await page.waitForSelector("#as-dialog[open]");
await jclick("#as-save");
await sleep(150);
pass("가온 채점 상태 갱신(회귀)", (await page.$eval('#as-table .row-open[data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent)) === "채점 완료");
await page.keyboard.press("Escape");
pass("가온 1440 오버플로", (await over()) <= 0, `+${await over()}px`);
// ── 뷰별 프로덕션 요소 (v10) ──
await jclick('[data-view="students"]');
pass("가온 수강생 보호자 열", await page.$eval("#stu-table thead", (t) => t.textContent.includes("보호자")));
pass("가온 수강생 요약 스트립", (await page.$$eval("#stu-stats > div", (n) => n.length)) === 5);
await jclick('[data-view="attendance"]');
pass("가온 출석 일별 합계", (await page.$eval("#att-foot", (f) => f.textContent.includes("/"))));
await jclick('[data-view="grades"]');
await page.evaluate(() => document.querySelector('[data-as="과학 실험"]').click());
pass("가온 성적 과목 필터", (await page.$$eval("#as-table tbody tr:not([hidden])", (n) => n.length)) === 1);
await page.evaluate(() => document.querySelector('[data-as="all"]').click());
await jclick('[data-view="calendar"]');
pass("가온 일정 유형 배지·범례", (await page.$$eval(".cal-ev.is-exam", (n) => n.length)) === 2 && (await page.$$eval(".cal-legend .key", (n) => n.length)) === 4);
pass("가온 일정 D-day", (await page.$eval("#up-list", (u) => u.textContent.includes("D-"))));
await jclick('[data-view="counsel"]');
pass("가온 예정 상담 3건", (await page.$$eval('section[aria-labelledby="co-next-h"] .due-row', (n) => n.length)) === 3);
await jclick('[data-view="report"]');
pass("가온 학기 요약 카드", (await page.$$eval("#rep-stats > div", (n) => n.length)) === 4);
await jclick('[data-view="notice"]');
await page.evaluate(() => document.querySelector('[data-nt="소통"]').click());
pass("가온 공지 분류 필터", (await page.$$eval(".notice-row:not([hidden])", (n) => n.length)) === 3);
await page.evaluate(() => document.querySelector('[data-nt="전체"]').click());
// ══════════ 두레대학 (6 탭) ══════════
await gotoFile("dure-enrollment");
for (const v of ["catalog","starred","enrollments","credits","archive","notices"]) {
await jclick(`.tab[data-view="${v}"]`);
pass(`두레 탭 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on")));
}
pass("두레 aria-current(공지)", await page.$eval('.tab[data-view="notices"]', (el) => el.getAttribute("aria-current") === "page"));
pass("두레 빈 상태 2종", await page.$eval("#en-empty", (el) => !el.hidden));
// 관심: 모달에서 남기기 → 관심 뷰 1행
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="phil115"]');
await jclick("#cd-star");
await jclick("#course-dialog .modal-close");
await jclick('.tab[data-view="starred"]');
pass("두레 관심 1행 + 목록 ★", (await page.$$("#starred-list .course-row")).length === 1 && (await page.$$("#starred-list .c-star")).length === 1);
// 신청 → 내역·이수 반영 → 총람 소계 → 공지
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="econ210"]');
await jclick("#cd-apply");
await sleep(200);
pass("두레 신청 블록 2(회귀)", (await page.$$(".tt-block")).length === 2);
await jclick('.tab[data-view="enrollments"]');
pass("두레 내역 1행·3학점", (await page.$$("#en-table tbody tr")).length === 1 && (await page.$eval("#en-credits", (el) => el.textContent)) === "3");
await jclick('.tab[data-view="credits"]');
const crMajor = await page.$eval("#cr-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " "));
pass("두레 이수 전공 반영", crMajor.includes("+ 3") && crMajor.includes("48"), crMajor);
await jclick('.tab[data-view="archive"]');
pass("두레 총람 12행(10+소계2)", (await page.$$("#ar-table tbody tr")).length === 12);
pass("두레 총람 여석 임박 표시", (await page.$$("#ar-table .low-seats")).length >= 1);
await jclick('.tab[data-view="notices"]');
pass("두레 공지 6건", (await page.$$("#notice-list li")).length === 6);
// 겹침 회귀: bio105 차단
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="econ210"]'); // 이미 신청됨 → 취소 버튼
await page.waitForSelector("#course-dialog[open]");
await jclick("#cd-cancel");
await sleep(150);
await jclick('[data-course="econ210"]');
await jclick("#cd-apply");
await sleep(150);
await jclick('[data-course="bio105"]');
await page.waitForSelector("#course-dialog[open]");
pass("두레 겹침 차단(회귀)", (await page.$eval("#cd-warn", (el) => !el.hidden && el.textContent.includes("겹칩니다"))) && (await page.$eval("#cd-apply", (b) => b.disabled)));
await jclick("#course-dialog .modal-close");
await jclick('.tab[data-view="enrollments"]');
await jclick("[data-cancel]");
pass("두레 내역 화면 취소", (await page.$$("#en-table tbody tr")).length === 0);
pass("두레 1440 오버플로", (await over()) <= 0, `+${await over()}px`);
// ══════════ 좁은 폭 ══════════
for (const w of [320, 390]) {
await page.setViewport({ width: w, height: 800 });
await gotoFile("gaon-lms");
const og = await over();
pass(`가온 ${w}px`, og <= 0, `+${og}px`);
await gotoFile("dure-enrollment");
const od = await over();
pass(`두레 ${w}px`, od <= 0, `+${od}px`);
}
// ── CSS 중괄호 균형 (v11 교훈) ──
import fs2 from "node:fs";
for (const f of ["gaon-lms/styles/main.css", "dure-enrollment/styles/main.css", "gaon-lms/styles/tokens.css", "dure-enrollment/styles/tokens.css"]) {
const c = fs2.readFileSync(path.join(root, "public/work", f), "utf8");
let depth = 0;
for (const ch of c) { if (ch === "{") depth++; if (ch === "}") depth--; }
pass(`CSS 중괄호 균형 ${f}`, depth === 0, `depth ${depth}`);
}
await browser.close();
console.log(results.join("\n"));
const fails = results.filter((r) => r.startsWith("FAIL")).length;
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAIL`);
process.exit(fails === 0 ? 0 : 1);