designpaca/apps/site/tools/e2e.mjs
Yun Chan 14cb138e1a feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E
- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출
- 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동
- safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거
- PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록)
- tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과
- 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체
- 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7
- 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
2026-08-23 14:33:15 +09:00

467 lines
26 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.

// L6 — 시나리오 E2E 게이트 (TDD RED→GREEN). node tools/e2e.mjs
//
// 실제 사용자 시나리오로 앱을 종단 검사한다. 단언 단위 = 개별 검사(요소별 터치타깃·대비는 요소마다 1단언).
// 목표: 총 단언 1000+ 전 통과(GREEN). RED: 신규 모바일 앱 기능 단언은 구현 전 실패해야 정상.
// 근거: apps/site/research/mobile-app-ux-v1.md (HIG·M3·NN/g·Baymard·WebKit 표준)
import puppeteer from "puppeteer-core";
import fs from "node:fs";
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const APPS = {
"gaon-lms": {
views: ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"],
tabs: ["dashboard", "students", "attendance", "grades"],
more: ["files", "calendar", "counsel", "report", "notice"],
},
"dure-enrollment": {
views: ["catalog", "starred", "enrollments", "credits", "archive", "notices"],
tabs: ["catalog", "starred", "enrollments", "credits"],
more: ["archive", "notices"],
},
};
let passCount = 0;
let failCount = 0;
const failures = [];
const suites = new Map();
const suite = (name) => {
if (!suites.has(name)) suites.set(name, { pass: 0, fail: 0 });
return suites.get(name);
};
const ok = (cond, sname, name, detail = "") => {
const S = suite(sname);
if (cond) { passCount++; S.pass++; }
else { failCount++; S.fail++; failures.push(`${name}${detail ? " — " + detail : ""}`); }
};
const okN = (good, total, sname, name, detail = "") => {
const S = suite(sname);
passCount += good; S.pass += good;
if (good < total) {
failCount += total - good; S.fail += total - good;
failures.push(`${name} — ${total - good}건 미달${detail ? ": " + detail : ""}`);
}
};
const UTILS = `
window.__lines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
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.__tapSweep = () => {
const EXEMPT_STRICT = ".undo-btn, .row-open";
const out = [];
document.querySelectorAll("button, input, select, textarea, a[href]").forEach((el) => {
if (!el.offsetParent) return;
const r = el.getBoundingClientRect();
const inline = el.matches(EXEMPT_STRICT) || el.tagName === "A";
const min = inline ? 24 : 44;
out.push({ key: (el.id || el.className || el.tagName).toString().slice(0, 24), w: Math.round(r.width), h: Math.round(r.height), min, bad: r.width < min || r.height < min });
});
return out;
};
window.__contrastSweep = () => {
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 = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.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;
};
let checked = 0; const bad = [];
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);
const fg = rgb(cs.color);
if (!fg) return;
const bg = bgOf(el);
const fgb = blend(fg, bg);
const l1 = lum(fgb); const 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;
checked++;
if (ratio < need) bad.push({ txt: txt.slice(0, 10), ratio, need });
});
return { checked, bad };
};
`;
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 openApp = async (app) => {
const href = url.pathToFileURL(path.join(ROOT, app, "index.html")).href;
try {
await page.goto(href, { waitUntil: "networkidle0", timeout: 45000 });
} catch {
await page.goto(href, { waitUntil: "domcontentloaded", timeout: 30000 });
}
await page.evaluate(() => document.fonts.ready);
await page.evaluate(UTILS);
};
const switchView = async (app, v) => {
const direct = await page.evaluate((v) => {
const bn = [...document.querySelectorAll(".bnav-item[data-view]")].find((b) => b.dataset.view === v && b.offsetParent);
if (bn) { bn.click(); return true; }
return false;
}, v);
if (direct) { await sleep(90); return; }
const viaMore = await page.evaluate((v) => {
const more = document.querySelector("[data-more]");
if (!more || !more.offsetParent) return false;
more.click();
const item = [...document.querySelectorAll(".more-list [data-view]")].find((b) => b.dataset.view === v);
if (!item) return false;
item.click();
return true;
}, v);
if (viaMore) { await sleep(140); return; }
await page.evaluate((v) => {
const b = document.querySelector('[data-view="' + v + '"]');
if (b) b.click();
}, v);
await sleep(90);
};
const jsclick = async (expr, S, name) => {
const done = await page.evaluate("(function(){try{const el=" + expr + ";if(el){el.click();return true}return false}catch(e){return false}})()");
ok(done, S, name);
return done;
};
const jswait = async (sel, S, name, t = 4000) => {
try { await page.waitForSelector(sel, { timeout: t }); ok(true, S, name); return true; }
catch { ok(false, S, name); return false; }
};
const isOn = async (v) =>
page.evaluate((v) => {
const el = document.querySelector("#view-" + v);
return !!el && el.classList.contains("is-on");
}, v).catch(() => false);
const stillHere = async (app) => page.url().includes(app);
// ════════ M4 — 앱 기반(표준·설치성) ════════
for (const [app] of Object.entries(APPS)) {
const S = "M4 앱 기반";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
const viewMeta = await page.evaluate(() => document.querySelector('meta[name="viewport"]')?.content || "");
ok(viewMeta.includes("viewport-fit=cover"), S, `${app} viewport-fit=cover`, viewMeta);
const manifest = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.getAttribute("href") || "");
ok(!!manifest && fs.existsSync(path.join(ROOT, app, manifest)), S, `${app} manifest 존재`, manifest);
for (const icon of ["icon-192.png", "icon-512.png"]) {
ok(fs.existsSync(path.join(ROOT, app, icon)), S, `${app} ${icon} 존재`);
}
ok(await page.evaluate(() => !!document.querySelector('link[rel="apple-touch-icon"]')), S, `${app} apple-touch-icon`);
ok((await page.evaluate(() => getComputedStyle(document.documentElement).webkitTapHighlightColor)) === "rgba(0, 0, 0, 0)", S, `${app} 탭 하이라이트 제거`);
const overscroll = await page.evaluate(() => getComputedStyle(document.body).overscrollBehaviorY || getComputedStyle(document.documentElement).overscrollBehaviorY || "");
ok(overscroll === "none" || overscroll === "contain", S, `${app} overscroll-behavior-y 위생`, overscroll);
const inputs = await page.evaluate(() => [...document.querySelectorAll("input, select, textarea")].map((el) => parseFloat(getComputedStyle(el).fontSize) || 0));
okN(inputs.filter((f) => f >= 16).length, Math.max(inputs.length, 1), S, `${app} 모바일 입력 16px+(iOS 확대 방지)`, inputs.join("/") + "px");
ok(await page.evaluate(() => !!document.querySelector(".bottom-nav")), S, `${app} 바텀 내비 존재`);
}
// ════════ M3+M1+M2 — 구조·터치타깃·대비 매트릭스(전 뷰 × 320/390/768) ════════
for (const [app, cfg] of Object.entries(APPS)) {
for (const w of [320, 390, 768]) {
await page.setViewport({ width: w, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
const S = `M${w} 매트릭스`;
for (const v of cfg.views) {
await switchView(app, v);
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(over <= 0, S, `${app}/${v}@${w} 오버플로 0`, `+${over}px`);
const h1 = await page.evaluate(() => window.__lines(document.querySelector(".view.is-on h2.v-h, h1")));
ok(h1 <= 1, S, `${app}/${v}@${w} 제목 1줄`, h1 + "줄");
if (w === 390) {
const brand = await page.evaluate(() => window.__lines(document.querySelector(".brand, .appbar .brand")));
ok(brand <= 1, S, `${app}/${v}@390 브랜드 1줄`, brand + "줄");
}
const nav = await page.evaluate(() => {
const n = document.querySelector(".bottom-nav");
if (!n) return null;
const cs = getComputedStyle(n);
// fixed 요소는 offsetParent 가 null — 보임 판정은 display·높이로
if (cs.display === "none" || n.getBoundingClientRect().height < 10) return null;
const r = n.getBoundingClientRect();
return { h: r.height, pb: parseFloat(cs.paddingBottom), fixed: cs.position === "fixed", bottom: r.bottom, vh: innerHeight };
});
ok(!!nav, S, `${app}/${v}@${w} 바텀 내비 노출`);
if (nav) {
ok(nav.fixed && nav.h >= 56, S, `${app}/${v}@${w} 내비 고정·높이`, `${nav.h.toFixed(0)}px`);
ok(Math.abs(nav.bottom - nav.vh) <= 1, S, `${app}/${v}@${w} 내비 화면 하단 밀착`, nav.bottom.toFixed(0) + "/" + nav.vh);
}
const padBottom = await page.evaluate(() => {
const el = document.querySelector(".main") || document.querySelector(".layout");
return parseFloat(getComputedStyle(el).paddingBottom);
});
ok(padBottom >= 64, S, `${app}/${v}@${w} 본문 하단 여백(내비 확보)`, padBottom + "px");
}
if (w === 390) {
for (const v of cfg.views) {
await switchView(app, v);
const taps = await page.evaluate(() => window.__tapSweep());
okN(taps.filter((t) => !t.bad).length, taps.length, "M1 터치타깃", `${app}/${v} ${taps.length}개`,
taps.filter((t) => t.bad).slice(0, 3).map((t) => `${t.key} ${t.w}x${t.h}<${t.min}`).join(", "));
const contrast = await page.evaluate(() => window.__contrastSweep());
okN(contrast.checked - contrast.bad.length, contrast.checked, "M2 대비", `${app}/${v}`,
contrast.bad.slice(0, 3).map((b) => `${b.txt} ${b.ratio}:1`).join(", "));
}
}
}
}
// ════════ S7 — 뒤로 가기(히스토리 = 앱 내 내비게이션) ════════
for (const [app, cfg] of Object.entries(APPS)) {
const S = "S7 뒤로 가기";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
await switchView(app, cfg.views[1]);
await switchView(app, cfg.views[2]);
await switchView(app, cfg.views[3]);
ok(await isOn(cfg.views[3]), S, `${app} 3번째 뷰 진입`);
await page.goBack(); await sleep(160);
if (!(await stillHere(app))) await openApp(app);
ok(await isOn(cfg.views[2]), S, `${app} back→직전 뷰(${cfg.views[2]})`, "히스토리에 뷰 기록 없음(RED) 또는 복원 실패");
await page.goBack(); await sleep(160);
if (!(await stillHere(app))) await openApp(app);
ok(await isOn(cfg.views[1]), S, `${app} back→2번째 뷰(${cfg.views[1]})`);
}
// 모달 + back
{
const S = "S7 뒤로 가기·모달";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp("gaon-lms");
await switchView("gaon-lms", "students");
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
await page.waitForSelector("#stu-dialog[open]");
ok(true, S, "가온 학생 시트 열림");
await page.goBack(); await sleep(260);
if (!(await stillHere("gaon-lms"))) await openApp("gaon-lms");
ok(await page.evaluate(() => !document.getElementById("stu-dialog").open), S, "가온 back→시트 닫힘");
ok(await page.evaluate(() => { const v = document.querySelector("#view-students"); return !!v && v.classList.contains("is-on"); }), S, "가온 시트 닫힘 후 뷰 유지");
await openApp("dure-enrollment");
await switchView("dure-enrollment", "catalog");
await page.evaluate(() => document.querySelector('[data-course="hum201"]').click());
await page.waitForSelector("#course-dialog[open]");
await page.goBack(); await sleep(260);
if (!(await stillHere("dure-enrollment"))) await openApp("dure-enrollment");
ok(await page.evaluate(() => !document.getElementById("course-dialog").open), S, "두레 back→강의계획 닫힘");
ok(await page.evaluate(() => { const v = document.querySelector("#view-catalog"); return !!v && v.classList.contains("is-on"); }), S, "두레 닫힘 후 뷰 유지");
}
// ════════ S8 — 바텀 시트(모바일 모달 문법) ════════
const SHEETS = [
["gaon-lms", "#stu-dialog", 'document.querySelector(\'[data-view="students"]\')', "document.querySelector('#stu-table [data-stu]')"],
["gaon-lms", "#as-dialog", 'document.querySelector(\'[data-view="grades"]\')', 'document.querySelector(\'[data-assign="a2"]\')'],
["dure-enrollment", "#course-dialog", null, 'document.querySelector(\'[data-course="hum201"]\')'],
];
for (const [app, dlgSel, prepExpr, openExpr] of SHEETS) {
const S = "S8 바텀 시트";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
if (prepExpr && (await jsclick(prepExpr, S, `${app}${dlgSel} 진입 준비`))) await sleep(120);
const opened = await jsclick(openExpr, S, `${app}${dlgSel} 열기 트리거`);
if (!opened) continue;
if (!(await jswait(`${dlgSel}[open]`, S, `${app}${dlgSel} 열림`))) continue;
await sleep(380);
const rect = await page.evaluate((sel) => {
const d = document.querySelector(sel);
const r = d.getBoundingClientRect();
const cs = getComputedStyle(d);
const grip = getComputedStyle(d, "::before");
return {
top: r.top, bottom: r.bottom, w: r.width, vw: innerWidth, vh: innerHeight,
radius: cs.borderTopLeftRadius, gripW: grip.width,
gripOn: grip.content !== "none" && grip.content !== undefined,
};
}, dlgSel);
ok(Math.abs(rect.bottom - rect.vh) <= 1, S, `${app}${dlgSel} 하단 밀착`, `${rect.bottom}/${rect.vh}`);
ok(Math.abs(rect.w - rect.vw) <= 1, S, `${app}${dlgSel} 전폭`, `${rect.w}/${rect.vw}`);
ok(parseFloat(rect.radius) > 0, S, `${app}${dlgSel} 상단 라운드`, rect.radius);
ok(rect.gripOn && parseFloat(rect.gripW) > 0, S, `${app}${dlgSel} 그래버 표시`, rect.gripW);
ok(rect.top >= rect.vh * 0.35, S, `${app}${dlgSel} 하단 65%(썸 존)`, `top ${Math.round((rect.top / rect.vh) * 100)}%`);
await page.keyboard.press("Escape");
}
// ════════ S9 — 더보기 시트 ════════
for (const [app, cfg] of Object.entries(APPS)) {
const S = "S9 더보기";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
ok(await page.evaluate(() => { const b = document.querySelector("[data-more]"); return !!b && !!b.offsetParent; }), S, `${app} 더보기 버튼 존재`);
ok(await page.evaluate((n) => document.querySelectorAll(".bnav-item").length === n, cfg.tabs.length + 1), S, `${app} 바텀 내비 구성`);
if (!(await jsclick('document.querySelector("[data-more]")', S, `${app} 더보기 열기`))) continue;
if (!(await jswait("#more-dialog[open]", S, `${app} 더보기 시트 열림`))) continue;
await sleep(220);
const moreItems = await page.evaluate(() => document.querySelectorAll(".more-list [data-view]").length);
ok(moreItems === cfg.more.length + cfg.tabs.length, S, `${app} 더보기 전체 화면 ${cfg.more.length + cfg.tabs.length}개`, String(moreItems));
const target = cfg.more[cfg.more.length - 1];
const expr = `[...document.querySelectorAll(".more-list [data-view]")].find((b) => b.dataset.view === "${target}")`;
if (await jsclick(expr, S, `${app} 더보기→${target} 클릭`)) {
await sleep(160);
ok(await isOn(target), S, `${app} 더보기→${target} 전환`);
ok(await page.evaluate(() => !document.getElementById("more-dialog").open), S, `${app} 선택 후 시트 닫힘`);
ok(await page.evaluate(() => { const b = document.querySelector("[data-more]"); return !!b && b.classList.contains("is-current"); }), S, `${app} 더보기 버튼 활성 표시`);
}
}
// ════════ S1 — 강사의 하루(장기 여정 · 실사용 10분+ 상당, 전 9화면 실조작) ════════
{
const S = "S1 강사 하루";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp("gaon-lms");
ok((await page.$$("#view-dashboard .headline > div")).length === 3, S, "아침·명단 3카드");
ok(await page.$eval("#view-dashboard", (v) => v.textContent.includes("92%")).catch(() => false), S, "아침·주간 출석률 92%");
await switchView("gaon-lms", "attendance");
for (const [i, expect] of [[1, "△"], [2, "✕"], [3, "○"]]) {
if (!(await jsclick(`document.querySelector("#att-table .att-btn[data-day='0']")`, S, `출석 칸 조작 ${i}`))) continue;
await sleep(90);
const mark = await page.$eval("#att-table .att-btn[data-day='0']", (b) => b.textContent).catch(() => null);
ok(mark === expect, S, `출석 순환 ${i}→${expect}`, String(mark));
}
await switchView("gaon-lms", "students");
await page.type("#q", "한지우");
await sleep(160);
ok((await page.$$("#view-students [data-stu]")).length === 1, S, "검색 축소 1명");
if (await jsclick(`document.querySelector("#view-students [data-stu]")`, S, "학생 카드 열기")) {
if (await jswait("#stu-dialog[open]", S, "학생 시트 열림")) {
const memoBefore = await page.$eval("#stu-note-list", (l) => l.children.length).catch(() => -1);
await page.type("#note-input", "3/10 등교 시간 재조정 2차");
await jsclick(`document.querySelector('#note-form button[type=submit]')`, S, "메모 제출");
await sleep(160);
ok(await page.$eval("#stu-note-list", (l) => l.children.length).catch(() => -2) === memoBefore + 1, S, "상담 메모 추가");
await page.keyboard.press("Escape");
await sleep(260);
}
}
await switchView("gaon-lms", "grades");
if (await jsclick(`document.querySelector('[data-assign="a2"]')`, S, "채점 모달 열기")) {
if (await jswait("#as-dialog[open]", S, "채점 시트 열림")) {
await page.$eval("#g1", (el) => { el.value = "150"; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {});
ok(await page.$eval("#as-save", (b) => b.disabled).catch(() => false), S, "채점 150 차단");
await page.$eval("#g1", (el) => { el.value = "90"; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {});
await jsclick(`document.getElementById("as-save")`, S, "채점 저장");
await sleep(160);
ok(await page.$eval('#as-table [data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent === "채점 완료").catch(() => false), S, "채점 저장→완료");
await page.keyboard.press("Escape");
await sleep(260);
}
}
await switchView("gaon-lms", "files");
if (await jsclick(`document.querySelector("[data-file]")`, S, "자료 배부 토글")) {
await sleep(120);
ok(await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed") === "false").catch(() => false), S, "자료 배부 상태 반전");
}
await switchView("gaon-lms", "calendar");
ok((await page.$$("#up-list li")).length === 7, S, "다가오는 일정 7건");
await switchView("gaon-lms", "counsel");
ok((await page.$$(".counsel-card")).length >= 3, S, "상담 카드 확인");
await switchView("gaon-lms", "report");
ok(await page.$eval("#report-table", (t) => t.textContent.includes("84.4점")).catch(() => false), S, "리포트 실계산");
await switchView("gaon-lms", "notice");
if (await jsclick(`document.querySelector(".notice-row")`, S, "공지 열기")) {
ok(await page.$eval(".notice-row", (r) => r.classList.contains("is-open")).catch(() => false), S, "공지 아코디언");
}
}
// ════════ S2 — 학생 정정기간 여정 ════════
{
const S = "S2 정정기간";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp("dure-enrollment");
await page.type("#q", "게임");
await sleep(160);
ok((await page.$$("#course-list .course-row")).length === 1, S, "검색 게임이론 1건");
await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {});
for (const id of ["econ315", "phil115"]) {
if (!(await jsclick(`document.querySelector('[data-course="${id}"]')`, S, `강의계획 ${id} 열기`))) continue;
if (!(await jswait("#course-dialog[open]", S, `${id} 시트 열림`))) continue;
await jsclick(`document.getElementById("cd-star")`, S, `${id} 관심 남기기`);
await page.keyboard.press("Escape");
await sleep(280);
}
await switchView("dure-enrollment", "starred");
ok((await page.$$("#starred-list .course-row")).length === 2, S, "관심 2과목");
ok(await page.$eval("#st-warn", (w) => !w.hidden && w.textContent.includes("학점")).catch(() => false), S, "관심 학점 사전 경고");
await switchView("dure-enrollment", "catalog");
// econ210(월·수 13-14:30) + econ315(화·목) 신청 → bio105(월 14-16)는 econ210 과 겹쳐 차단
for (const [id, blocked] of [["econ210", false], ["econ315", false], ["bio105", true]]) {
if (!(await jsclick(`document.querySelector('[data-course="${id}"]')`, S, `${id} 열기`))) continue;
if (!(await jswait("#course-dialog[open]", S, `${id} 시트 열림`))) continue;
await sleep(160);
if (blocked) {
ok(await page.$eval("#cd-apply", (b) => b.disabled).catch(() => false), S, "생명과학 겹침 차단(econ210 월 13-14:30)");
ok(await page.$eval("#cd-warn", (w) => !w.hidden && w.textContent.includes("겹칩니다")).catch(() => false), S, "겹침 사유 안내");
await page.keyboard.press("Escape");
await sleep(280);
} else {
await jsclick(`document.getElementById("cd-apply")`, S, `${id} 신청`);
await sleep(160);
}
}
ok(await page.$eval("#credit-now", (c) => c.textContent === "6").catch(() => false), S, "신청 6학점");
await switchView("dure-enrollment", "enrollments");
if (await jsclick(`document.querySelector("[data-cancel]")`, S, "신청 취소")) {
await sleep(160);
ok(await page.$eval("#en-credits", (c) => c.textContent === "3").catch(() => false), S, "취소 후 3학점");
if (await jsclick(`document.querySelector("#en-status .undo-btn")`, S, "실행취소 클릭")) {
await sleep(160);
ok(await page.$eval("#en-credits", (c) => c.textContent === "6").catch(() => false), S, "실행취소 복원 6학점");
}
}
await switchView("dure-enrollment", "credits");
ok(await page.$eval("#cr-stats", (s) => s.textContent.includes("6학점")).catch(() => false), S, "이수 요약 6학점 반영");
ok(await page.$eval("#cr-table", (t) => t.textContent.includes("+ 6")).catch(() => false), S, "이수 표 전공 +6 반영");
await switchView("dure-enrollment", "catalog");
ok((await page.$$(".tt-block")).length === 4, S, "시간표 블록 4(2과목×2일)");
}
// ════════ 리포트 ════════
await browser.close();
const total = passCount + failCount;
const report = [
`# E2E 시나리오 게이트 — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
``,
`단언 ${total} · 통과 ${passCount} · 실패 ${failCount} — ${failCount ? "**RED/미통과**" : "**GREEN — 전 단언 통과**"}`,
``,
`| 스위트 | 통과 | 실패 |`,
`|---|---|---|`,
...[...suites.entries()].map(([k, v]) => `| ${k} | ${v.pass} | ${v.fail} |`),
...(failCount ? ["", "## 실패 목록(최대 60)", ...failures.slice(0, 60).map((f) => `- ${f}`)] : []),
].join("\n");
fs.writeFileSync(path.join(here, "e2e-report.md"), report);
console.log(`단언 ${total} · 통과 ${passCount} · 실패 ${failCount}`);
console.log([...suites.entries()].map(([k, v]) => `${k}: ${v.pass}/${v.pass + v.fail}`).join(" · "));
console.log(failures.slice(0, 12).map((f) => "FAIL " + f).join("\n"));
if (failures.length > 12) console.log(`… 외 ${failures.length - 12}건`);
console.log(`\nE2E: ${failCount ? `${failCount} FAIL (RED/미통과)` : "ALL GREEN"} — 리포트 tools/e2e-report.md`);
process.exit(failCount ? 1 : 0);