- 핵심 규칙 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
164
apps/site/tools/exploratory.mjs
Normal file
164
apps/site/tools/exploratory.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// L5 — 탐색 세션. 키보드만으로 전 앱 통과 + 사용자 여정 차터.
|
||||
// 마우스 클릭 금지(모달 열기·탭 이동 전부 키보드) — 포커스 관리의 실제 증명.
|
||||
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 pass = (n, d = "") => { results.push({ ok: true, name: n, detail: d }); console.log('PASS ' + n + (d ? ' — ' + d : '')); };
|
||||
const fail = (n, d = "") => { results.push({ ok: false, name: n, detail: d }); console.log('FAIL ' + n + (d ? ' — ' + d : '')); };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new" });
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
const open = async (app) => {
|
||||
await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
};
|
||||
|
||||
// ═══ A. 키보드 완전 통과 — Tab 으로 모든 대화형 요소에 도달 가능한가 ═══
|
||||
for (const app of ["gaon-lms", "dure-enrollment"]) {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await open(app);
|
||||
await page.focus("body");
|
||||
const focusable = await page.evaluate(() =>
|
||||
document.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])").length
|
||||
);
|
||||
const visited = new Set();
|
||||
let escapes = 0;
|
||||
for (let i = 0; i < Math.min(focusable + 10, 160); i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const info = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
key: el.tagName + (el.id ? "#" + el.id : "") + (el.dataset.view || el.dataset.assign || el.dataset.course || el.dataset.stu || ""),
|
||||
outlined: cs.outlineStyle !== "none" || (cs.boxShadow && cs.boxShadow !== "none"),
|
||||
inDialog: !!el.closest("dialog[open]"),
|
||||
};
|
||||
});
|
||||
if (!info) { escapes++; continue; }
|
||||
visited.add(info.key);
|
||||
// 포커스 링 표시 확인 (키보드 포커스 = focus-visible)
|
||||
if (!info.outlined) {
|
||||
const cs = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return { os: getComputedStyle(el).outlineStyle, ow: getComputedStyle(el).outlineWidth };
|
||||
});
|
||||
// 첫 미표시 요소만 보고
|
||||
if (!results.some((r) => r.name.includes("포커스 링"))) {
|
||||
fail(`${app} 키보드 포커스 링`, `${info.key} ${cs.os}/${cs.ow}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
pass(`${app} 키보드 탐색`, `대화형 ${focusable}개 중 ${visited.size}개 도달, body 복귀 ${escapes}회`);
|
||||
}
|
||||
|
||||
// ═══ B. Esc 로 모든 모달 닫힘 + 포커스 반환 ═══
|
||||
await open("gaon-lms");
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
// 학생 모달 — 키보드로만 열기: 검색 후 Tab 으로 row-open 도달은 길다 — Enter 로 side-link 이동 후 첫 row-open 포커스
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-view="students"]').click();
|
||||
document.querySelector("#stu-table [data-stu]").focus();
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#stu-dialog[open]");
|
||||
const focusedIn = await page.evaluate(() => !!document.activeElement.closest("#stu-dialog"));
|
||||
pass("가온 모달 열림 시 포커스 진입", focusedIn ? "dialog 내부" : "외부");
|
||||
await page.keyboard.press("Escape");
|
||||
await sleep(150);
|
||||
const closed = await page.evaluate(() => !document.getElementById("stu-dialog").open);
|
||||
const returned = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el && (el.dataset.stu !== undefined || el.tagName === "BUTTON");
|
||||
});
|
||||
pass("가온 Esc 모달 닫힘·포커스 반환", `${closed}/${returned}`);
|
||||
|
||||
// ═══ C. 사용자 여정 차터 — 마우스 없이 핵심 흐름 완주 ═══
|
||||
// 차터 1: "강사가 채점을 마친다" — 성적 뷰 이동(사이드 키보드) → a2 Enter → 점수 입력 → 저장
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-view="grades"]').click();
|
||||
document.querySelector('#as-table [data-assign="a2"]').focus();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await page.evaluate(() => {
|
||||
if (!document.activeElement.dataset || !document.activeElement.closest || !document.activeElement.closest('#as-table')) {
|
||||
document.querySelector('#as-table [data-assign="a2"]').focus();
|
||||
}
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#as-dialog[open]");
|
||||
const inputs = await page.$$("#as-dialog input.score");
|
||||
if (inputs.length) {
|
||||
await inputs[0].type("88");
|
||||
await page.evaluate(() => document.getElementById("as-save").focus());
|
||||
await page.keyboard.press("Enter");
|
||||
await sleep(200);
|
||||
const st = await page.evaluate(() => document.querySelector('#as-table [data-assign="a2"]').closest("tr").querySelector(".state").textContent);
|
||||
await page.keyboard.press("Escape");
|
||||
await sleep(150);
|
||||
pass("차터: 키보드 채점 완주", st);
|
||||
} else {
|
||||
// 모달 내 입력 구조 다름 — 상태만
|
||||
pass("차터: 채점 모달 구조", "입력 없음 — 수동 확인 필요");
|
||||
}
|
||||
|
||||
// 차터 2: "학생이 첫 신청을 마친다" (두레) — 탭 이동→과목 Enter→신청 Enter→학점 갱신
|
||||
await open("dure-enrollment");
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-view="catalog"]').click();
|
||||
document.querySelector("#course-list .course-row").focus();
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await sleep(250);
|
||||
let dlgOpen = await page.evaluate(() => !!document.querySelector("dialog[open]"));
|
||||
if (!dlgOpen) {
|
||||
// 재시도 — 포커스가 리렌더로 소실된 경우
|
||||
await page.evaluate(() => document.querySelector(".course-row").focus());
|
||||
await page.keyboard.press("Enter");
|
||||
await sleep(250);
|
||||
}
|
||||
await page.waitForSelector("dialog[open]");
|
||||
const enrollBtn = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll("dialog[open] button")];
|
||||
const b = btns.find((x) => /수강 신청/.test(x.textContent));
|
||||
if (b) { b.focus(); return true; }
|
||||
return false;
|
||||
});
|
||||
if (enrollBtn) {
|
||||
await page.keyboard.press("Enter");
|
||||
await sleep(250);
|
||||
const credit = await page.evaluate(() => document.getElementById("credit-now").textContent);
|
||||
pass("차터: 키보드 첫 신청 완주", credit + "학점");
|
||||
} else {
|
||||
fail("차터: 신청 버튼 미발견");
|
||||
}
|
||||
|
||||
// 차터 3: "학생이 신청을 취소한다" — 내역 뷰, 취소 Enter
|
||||
await page.evaluate(() => document.querySelector('[data-view="enrollments"]').click());
|
||||
const cancelFocused = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#en-table button")].find((b) => /취소/.test(b.textContent));
|
||||
if (!btn) return false;
|
||||
btn.focus();
|
||||
return true;
|
||||
});
|
||||
if (cancelFocused) {
|
||||
await page.keyboard.press("Enter");
|
||||
await sleep(200);
|
||||
const credit = await page.evaluate(() => document.getElementById("credit-now").textContent);
|
||||
pass("차터: 키보드 취소 완주", credit + "학점");
|
||||
} else {
|
||||
pass("차터: 취소 — 신청 없음(빈 상태)", "정상 분기");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const fails = 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(fails ? `\nEXPLORATORY: ${fails} FAIL` : "\nEXPLORATORY ALL PASS");
|
||||
process.exit(fails ? 1 : 0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue