- 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(양앱) 범프 후 전체 재검증
467 lines
26 KiB
JavaScript
467 lines
26 KiB
JavaScript
// 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);
|