designpaca/apps/site/tools/e2e.mjs
Yun Chan d4cd9ca49d fix(site,skill): 모바일 상단 바 수리 — 탭 전환 스크롤 리셋과 단일 sticky 헤더
사용자 보고 '상단 바가 페이지마다 왔다갔다하며 패딩이 없고 포지션도 이상하다'를 계측으로 원인 규명:
- 탭 전환 시 이전 뷰의 window.scrollY 잔류 — 짧은 뷰에선 클램프(600→49)되어 헤더가 반쯤 잘림. setCurrentView 에 scrollTo(0,0) 리셋(양앱)
- ≤900 헤더 이중 스택(브랜드 줄 43px+크럼 52px)·비sticky·safe-top 무 — .side 숨기고 .topbar 를 sticky+safe-top 단일 헤더로(두레 앱바 문법). 데스크톱 무변경
- 재발 방지: e2e M5 헤더·스크롤 스위트 10단언(스크롤 300px+에서 탭 전환 → scrollY=0·헤더 top=0), 시각 기준 갱신(390px 9화면 의도 변경), verify L0~L6 재통과
- 스킬 mobile-app-ux.md 에 '뷰 전환은 스크롤을 리셋·헤더는 하나' 규칙 반영. 가온 CSS v23
2026-08-23 14:50:06 +09:00

498 lines
27 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} 바텀 내비 존재`);
}
// ════════ M5 — 헤더·스크롤(탭 전환은 화면 맨 위에서, 헤더는 상단에 남는다) ════════
// 실측 사고: 이전 뷰의 스크롤이 남아 짧은 뷰에서 클램프되면 헤더가 반쯤 잘리고,
// 뷰마다 크롬 위치가 제각각이 되어 "상단 바가 왔다갔다한다"는 보고로 나타났다
for (const [app, cfg] of Object.entries(APPS)) {
const S = "M5 헤더·스크롤";
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(app);
const headerSel = app === "gaon-lms" ? ".topbar" : ".appbar";
const head = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return { pos: cs.position, top: Math.round(r.top), h: Math.round(r.height) };
}, headerSel);
ok(!!head && (head.pos === "sticky" || head.pos === "fixed"), S, `${app} 헤더 sticky/fixed`, head ? head.pos : "요소 없음");
ok(head && head.top === 0 && head.h >= 44, S, `${app} 헤더 상단 밀착·높이`, head ? head.top + "px/" + head.h + "px" : "-");
// 스크롤 내린 상태에서 탭 전환 — 새 화면은 맨 위에서 시작해야 한다
await page.evaluate(() => window.scrollTo(0, 500));
await new Promise((r) => setTimeout(r, 60));
const scrolled = await page.evaluate(() => window.scrollY);
await switchView(app, cfg.views[1]);
const after = await page.evaluate((sel) => ({
y: window.scrollY,
top: Math.round(document.querySelector(sel).getBoundingClientRect().top),
}), headerSel);
ok(scrolled > 300, S, `${app} 사전 조건: 300px+ 스크롤`, scrolled + "px");
ok(after.y === 0, S, `${app} 탭 전환 후 스크롤 리셋`, after.y + "px");
ok(after.top === 0, S, `${app} 탭 전환 후 헤더 상단 밀착`, after.top + "px");
}
// ════════ 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);