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(양앱) 범프 후 전체 재검증
This commit is contained in:
Yun Chan 2026-08-23 14:33:15 +09:00
parent 72337b7ee0
commit 14cb138e1a
133 changed files with 2139 additions and 208 deletions

467
apps/site/tools/e2e.mjs Normal file
View file

@ -0,0 +1,467 @@
// 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);