// 확장된 두 앱 감사: 신규 뷰 전부 + 기존 기능 회귀 + 오버플로 import puppeteer from "puppeteer-core"; import path from "node:path"; const root = "D:/workspace/designpaca/apps/site"; const chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"; const results = []; const pass = (name, ok, detail = "") => results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const browser = await puppeteer.launch({ executablePath: chrome, headless: "new", args: ["--no-sandbox", "--disable-gpu", "--force-device-scale-factor=1"], defaultViewport: { width: 1440, height: 900 }, }); const page = await browser.newPage(); page.on("pageerror", (e) => results.push(`FAIL pageerror — ${e.message}`)); const jclick = (sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) throw new Error("no " + s); el.click(); }, sel); const over = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); const gotoFile = async (app) => page.goto(`file:///${path.join(root, "public/work", app, "index.html")}`, { waitUntil: "networkidle0" }); const gotoWorkFile = async (...parts) => page.goto(`file:///${path.join(root, "public/work", ...parts)}`, { waitUntil: "networkidle0" }); const centeredDialog = (selector) => page.$eval(selector, (dialog) => { const rect = dialog.getBoundingClientRect(); return { deltaX: Math.abs(rect.left + rect.width / 2 - innerWidth / 2), deltaY: Math.abs(rect.top + rect.height / 2 - innerHeight / 2), inViewport: rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight, }; }); // ══════════ 가온 학적부 (8 뷰) ══════════ await gotoFile("gaon-lms"); for (const v of ["dashboard","students","attendance","grades","files","calendar","counsel","report","notice"]) { await jclick(`[data-view="${v}"]`); pass(`가온 뷰 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on"))); } pass("가온 aria-current(공지)", await page.$eval('[data-view="notice"]', (el) => el.getAttribute("aria-current") === "page")); // 사이드바 그룹 헤더 3개 pass("가온 사이드 그룹 3개", (await page.$$eval(".side-group", (n) => n.length)) === 3); // 출석부: 셀 순환 + 주 전환 + 반 전환 await jclick('[data-view="attendance"]'); const cellBefore = await page.$eval(".att-btn", (b) => b.textContent); await jclick(".att-btn"); const cellAfter = await page.$eval(".att-btn", (b) => b.textContent); pass("가온 출석 순환 ○→△", cellBefore === "○" && cellAfter === "△"); await jclick("#att-prev"); pass("가온 주 전환(1주)", (await page.$eval("#att-week", (el) => el.textContent)) === "3월 1주"); await jclick("#att-next"); await page.evaluate(() => document.querySelector("#att-class [data-sub='코딩 입문']").click()); await sleep(150); const codingRows = await page.$$eval("#att-table tbody tr", (rows) => rows.map((r) => r.textContent.slice(0, 12))); pass("가온 반 전환(코딩 입문 3행, 휴학 포함)", codingRows.length === 3, codingRows.join("|")); // 자료실: 필터 + 배부 토글 await jclick('[data-view="files"]'); await page.evaluate(() => document.querySelector('#file-chips [data-kind="PDF"]').click()); await sleep(150); pass("가온 자료 필터 PDF", (await page.$$("#file-body tr")).length === 2); const giveBefore = await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed")); await jclick("[data-file]"); pass("가온 배부 토글", (await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"))) !== giveBefore); // 달력: 42칸 + 오늘 1 + 예정 7 await jclick('[data-view="calendar"]'); pass("가온 달력 42칸·오늘 1", (await page.$$("#cal-table td")).length === 42 && (await page.$$(".is-today")).length === 1); pass("가온 다가오는 일정 7건", (await page.$$("#up-list li")).length === 7); // 상담: 필터 + 카드→학생 모달 await jclick('[data-view="counsel"]'); await page.evaluate(() => document.querySelector('#co-chips [data-sub="국어 논술 A"]').click()); await sleep(150); pass("가온 상담 필터(국어 2건)", (await page.$$(".counsel-card")).length === 2); await jclick(".counsel-card"); pass("가온 상담 카드→학생 모달", await page.$eval("#stu-dialog", (d) => d.open)); await page.keyboard.press("Escape"); // 리포트: 4행 + 수학 행 계산값 await jclick('[data-view="report"]'); pass("가온 리포트 4행", (await page.$$("#report-table tbody tr")).length === 4); const repMath = await page.$eval("#report-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " ")); pass("가온 리포트 수학 행 실계산", repMath.includes("3명") && repMath.includes("91%") && repMath.includes("96%") && repMath.includes("84.4점")); // 회귀: 검색/칩/학생 모달/메모/채점 await jclick('[data-view="students"]'); await page.type("#q", "박"); await sleep(200); pass("가온 검색 축소(회귀)", (await page.$$("#view-students [data-stu]")).length === 1); await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); }); await jclick("#view-students [data-stu]"); await page.waitForSelector("#stu-dialog[open]"); await page.type("#note-input", "확정 회귀 검사"); await jclick("#note-form button[type=submit]"); await sleep(150); pass("가온 메모 추가(회귀)", (await page.$$("#stu-note-list li")).length === 2); await jclick("#stu-dialog .modal-close"); await jclick('[data-view="grades"]'); await page.evaluate(() => document.querySelector('[data-assign="a2"]').click()); await page.waitForSelector("#as-dialog[open]"); await jclick("#as-save"); await sleep(150); pass("가온 채점 상태 갱신(회귀)", (await page.$eval('#as-table .row-open[data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent)) === "채점 완료"); await page.keyboard.press("Escape"); pass("가온 1440 오버플로", (await over()) <= 0, `+${await over()}px`); // ── 뷰별 프로덕션 요소 (v10) ── await jclick('[data-view="students"]'); pass("가온 수강생 보호자 열", await page.$eval("#stu-table thead", (t) => t.textContent.includes("보호자"))); pass("가온 수강생 요약 스트립", (await page.$$eval("#stu-stats > div", (n) => n.length)) === 5); await jclick('[data-view="attendance"]'); pass("가온 출석 일별 합계", (await page.$eval("#att-foot", (f) => f.textContent.includes("/")))); await jclick('[data-view="grades"]'); await page.evaluate(() => document.querySelector('[data-as="과학 실험"]').click()); pass("가온 성적 과목 필터", (await page.$$eval("#as-table tbody tr:not([hidden])", (n) => n.length)) === 1); await page.evaluate(() => document.querySelector('[data-as="all"]').click()); await jclick('[data-view="calendar"]'); pass("가온 일정 유형 배지·범례", (await page.$$eval(".cal-ev.is-exam", (n) => n.length)) === 2 && (await page.$$eval(".cal-legend .key", (n) => n.length)) === 4); pass("가온 일정 D-day", (await page.$eval("#up-list", (u) => u.textContent.includes("D-")))); await jclick('[data-view="counsel"]'); pass("가온 예정 상담 3건", (await page.$$eval('section[aria-labelledby="co-next-h"] .due-row', (n) => n.length)) === 3); await jclick('[data-view="report"]'); pass("가온 학기 요약 카드", (await page.$$eval("#rep-stats > div", (n) => n.length)) === 4); await jclick('[data-view="notice"]'); await page.evaluate(() => document.querySelector('[data-nt="소통"]').click()); pass("가온 공지 분류 필터", (await page.$$eval(".notice-row:not([hidden])", (n) => n.length)) === 3); await page.evaluate(() => document.querySelector('[data-nt="전체"]').click()); // ══════════ 두레대학 (6 탭) ══════════ await gotoFile("dure-enrollment"); for (const v of ["catalog","starred","enrollments","credits","archive","notices"]) { await jclick(`.tab[data-view="${v}"]`); pass(`두레 탭 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on"))); } pass("두레 aria-current(공지)", await page.$eval('.tab[data-view="notices"]', (el) => el.getAttribute("aria-current") === "page")); pass("두레 빈 상태 2종", await page.$eval("#en-empty", (el) => !el.hidden)); // 관심: 모달에서 남기기 → 관심 뷰 1행 await jclick('.tab[data-view="catalog"]'); await jclick('[data-course="phil115"]'); await jclick("#cd-star"); await jclick("#course-dialog .modal-close"); await jclick('.tab[data-view="starred"]'); pass("두레 관심 1행 + 목록 ★", (await page.$$("#starred-list .course-row")).length === 1 && (await page.$$("#starred-list .c-star")).length === 1); // 신청 → 내역·이수 반영 → 총람 소계 → 공지 await jclick('.tab[data-view="catalog"]'); await jclick('[data-course="econ210"]'); await jclick("#cd-apply"); await sleep(200); pass("두레 신청 블록 2(회귀)", (await page.$$(".tt-block")).length === 2); await jclick('.tab[data-view="enrollments"]'); pass("두레 내역 1행·3학점", (await page.$$("#en-table tbody tr")).length === 1 && (await page.$eval("#en-credits", (el) => el.textContent)) === "3"); await jclick('.tab[data-view="credits"]'); const crMajor = await page.$eval("#cr-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " ")); pass("두레 이수 전공 반영", crMajor.includes("+ 3") && crMajor.includes("48"), crMajor); await jclick('.tab[data-view="archive"]'); pass("두레 총람 12행(10+소계2)", (await page.$$("#ar-table tbody tr")).length === 12); pass("두레 총람 여석 임박 표시", (await page.$$("#ar-table .low-seats")).length >= 1); await jclick('.tab[data-view="notices"]'); pass("두레 공지 6건", (await page.$$("#notice-list li")).length === 6); // 겹침 회귀: bio105 차단 await jclick('.tab[data-view="catalog"]'); await jclick('[data-course="econ210"]'); // 이미 신청됨 → 취소 버튼 await page.waitForSelector("#course-dialog[open]"); await jclick("#cd-cancel"); await sleep(150); await jclick('[data-course="econ210"]'); await jclick("#cd-apply"); await sleep(150); await jclick('[data-course="bio105"]'); await page.waitForSelector("#course-dialog[open]"); pass("두레 겹침 차단(회귀)", (await page.$eval("#cd-warn", (el) => !el.hidden && el.textContent.includes("겹칩니다"))) && (await page.$eval("#cd-apply", (b) => b.disabled))); await jclick("#course-dialog .modal-close"); await jclick('.tab[data-view="enrollments"]'); await jclick("[data-cancel]"); pass("두레 내역 화면 취소", (await page.$$("#en-table tbody tr")).length === 0); pass("두레 1440 오버플로", (await over()) <= 0, `+${await over()}px`); // ══════════ 프로덕션 마감(v19): 검증 · 실행취소 · 단축키 · 인쇄 ══════════ await gotoFile("gaon-lms"); // 채점 검증: 범위 밖 점수는 이유를 말하고 저장을 막는다. 정상 복귀하면 푼다 await jclick('[data-view="grades"]'); await page.evaluate(() => document.querySelector('[data-assign="a2"]').click()); await page.waitForSelector("#as-dialog[open]"); await page.$eval("#g1", (el) => { el.value = "150"; el.dispatchEvent(new Event("input", { bubbles: true })); }); pass( "가온 채점 범위 검증(150 차단)", (await page.$eval("#as-warn", (w) => !w.hidden && w.textContent.includes("0에서 100"))) && (await page.$eval("#as-save", (b) => b.disabled)) && (await page.$eval("#g1", (i) => i.getAttribute("aria-invalid") === "true")) ); await page.$eval("#g1", (el) => { el.value = "92"; el.dispatchEvent(new Event("input", { bubbles: true })); }); pass( "가온 채점 정상 복귀(차단 해제)", (await page.$eval("#as-warn", (w) => w.hidden)) && !(await page.$eval("#as-save", (b) => b.disabled)) ); await page.keyboard.press("Escape"); // 단축키: 숫자키 뷰 전환, / 검색 포커스 await page.focus("body"); await page.keyboard.press("3"); pass("가온 단축키 3(출석부)", await page.$eval("#view-attendance", (el) => el.classList.contains("is-on"))); await page.keyboard.press("/"); pass( "가온 단축키 /(수강생 검색)", (await page.$eval("#view-students", (el) => el.classList.contains("is-on"))) && (await page.evaluate(() => document.activeElement.id === "q")) ); // 알림 센터 Esc 닫힘 await page.evaluate(() => document.getElementById("bell").click()); await page.keyboard.press("Escape"); pass("가온 알림 Esc 닫힘", await page.$eval("#bell-menu", (m) => !m.classList.contains("is-open"))); pass("가온 인쇄 스타일", fs2.readFileSync(path.join(root, "public/work/gaon-lms/styles/main.css"), "utf8").includes("@media print")); await gotoFile("dure-enrollment"); // 실행취소: 신청 → 취소 → 되돌리기 → 원상 복구 await jclick('.tab[data-view="catalog"]'); await jclick('[data-course="econ210"]'); await page.waitForSelector("#course-dialog[open]"); await jclick("#cd-apply"); await sleep(150); await jclick('.tab[data-view="enrollments"]'); await jclick("[data-cancel]"); pass("두레 취소 안내·실행취소 버튼", await page.$eval("#en-status", (s) => !!s.querySelector(".undo-btn"))); await page.evaluate(() => document.querySelector("#en-status .undo-btn").click()); await sleep(150); pass( "두레 실행취소 복원", (await page.$$("#en-table tbody tr")).length === 1 && (await page.$eval("#credit-now", (el) => el.textContent)) === "3" ); // 단축키: 숫자키 탭 전환, / 검색 포커스 await page.focus("body"); await page.keyboard.press("2"); pass("두레 단축키 2(관심 과목)", await page.$eval("#view-starred", (el) => el.classList.contains("is-on"))); await page.keyboard.press("/"); pass( "두레 단축키 /(과목 검색)", (await page.$eval("#view-catalog", (el) => el.classList.contains("is-on"))) && (await page.evaluate(() => document.activeElement.id === "q")) ); pass("두레 인쇄 스타일", fs2.readFileSync(path.join(root, "public/work/dure-enrollment/styles/main.css"), "utf8").includes("@media print")); // ══════════ 좁은 폭 ══════════ for (const w of [320, 390]) { await page.setViewport({ width: w, height: 800 }); await gotoFile("gaon-lms"); const og = await over(); pass(`가온 ${w}px`, og <= 0, `+${og}px`); await gotoFile("dure-enrollment"); const od = await over(); pass(`두레 ${w}px`, od <= 0, `+${od}px`); } // ── 사용자 보고 회귀: 팝업·모달은 좌상단에 붙거나 화면 밖으로 잘리면 안 된다 ── await page.setViewport({ width: 390, height: 320 }); await gotoFile("gaon-lms"); await jclick("#bell"); const bellRect = await page.$eval("#bell-menu", (menu) => { const r = menu.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, clientHeight: menu.clientHeight, scrollHeight: menu.scrollHeight }; }); pass("가온 짧은 화면 알림판 뷰포트·스크롤", bellRect.left >= 0 && bellRect.top >= 0 && bellRect.right <= 390 && bellRect.bottom <= 320 && bellRect.clientHeight > 0 && bellRect.scrollHeight >= bellRect.clientHeight, JSON.stringify(bellRect)); for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }, { width: 2560, height: 900 }]) { await page.setViewport(viewport); await gotoFile("materia-interior"); await jclick('[onclick^="openProjectModal"]'); const projectPosition = await centeredDialog("#projectModal"); pass(`마테리아 프로젝트 모달 중앙 ${viewport.width}px`, projectPosition.inViewport && projectPosition.deltaX <= 1 && projectPosition.deltaY <= 1, JSON.stringify(projectPosition)); const projectScrollLock = await page.$eval("html", (root) => getComputedStyle(root).overflow); pass(`마테리아 프로젝트 모달 배경 잠금 ${viewport.width}px`, projectScrollLock === "hidden", projectScrollLock); await page.keyboard.press("Escape"); await gotoWorkFile("materia-interior", "projects", "hannam-duplex", "index.html"); await page.waitForSelector(".gallery-card-action"); await jclick(".gallery-card-action"); const lightboxPosition = await centeredDialog("#lightboxDialog"); pass(`마테리아 갤러리 모달 중앙 ${viewport.width}px`, lightboxPosition.inViewport && lightboxPosition.deltaX <= 1 && lightboxPosition.deltaY <= 1, JSON.stringify(lightboxPosition)); const lightboxScrollLock = await page.$eval("html", (root) => getComputedStyle(root).overflow); pass(`마테리아 갤러리 모달 배경 잠금 ${viewport.width}px`, lightboxScrollLock === "hidden", lightboxScrollLock); await page.keyboard.press("Escape"); } await page.setViewport({ width: 390, height: 320 }); await gotoFile("hannam-jari"); await jclick(".menu-button"); const mobileNav = await page.$eval(".site-nav", (nav) => { const r = nav.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, clientHeight: nav.clientHeight, scrollHeight: nav.scrollHeight }; }); pass("한남자리 짧은 화면 메뉴 뷰포트·스크롤", mobileNav.top >= 0 && mobileNav.bottom <= 320 && mobileNav.clientHeight > 0 && mobileNav.scrollHeight >= mobileNav.clientHeight, JSON.stringify(mobileNav)); await gotoWorkFile("hannam-jari", "admin", "index.html"); await jclick("[data-select-row]"); const inspectorScroll = await page.$eval(".inspector-scroll", (panel) => ({ clientHeight: panel.clientHeight, scrollHeight: panel.scrollHeight })); pass("한남자리 짧은 화면 조사관 내부 스크롤", inspectorScroll.clientHeight > 0 && inspectorScroll.scrollHeight >= inspectorScroll.clientHeight, JSON.stringify(inspectorScroll)); // ── 사용자 보고 회귀: 2560px에서 사진 한 장이 본문 밀도를 압도하지 않는다 ── await page.setViewport({ width: 2560, height: 900 }); await gotoFile("griddle-club"); const griddleWide = await page.$eval(".hero", (hero) => { const rect = (selector, scope = hero) => { const r = scope.querySelector(selector).getBoundingClientRect(); return { left: r.left, width: r.width, right: r.right }; }; const visualLineCount = (node) => { const range = document.createRange(); range.selectNodeContents(node); return new Set([...range.getClientRects()].map((r) => Math.round(r.top))).size; }; const heroRect = hero.getBoundingClientRect(); const rail = document.querySelector(".menu-grid").getBoundingClientRect(); const process = document.querySelector(".process-grid"); const processRect = process.getBoundingClientRect(); const processHeading = process.querySelector(".process-copy h2"); return { hero: { left: heroRect.left, width: heroRect.width, right: heroRect.right }, copy: rect(".hero-copy"), media: rect(".hero-media"), contentRail: { left: rail.left, width: rail.width, right: rail.right }, process: { rail: { left: processRect.left, width: processRect.width, right: processRect.right }, copy: rect(".process-copy", process), media: rect(".process-media", process), heading: rect(".process-copy h2", process), headingLines: visualLineCount(processHeading), }, }; }); const heroCentered = Math.abs(griddleWide.hero.left + griddleWide.hero.width / 2 - 1280) <= 1; pass("철판클럽 2560px 히어로·이미지 비율", heroCentered && griddleWide.hero.width >= 2140 && griddleWide.hero.width <= 2162 && griddleWide.copy.width >= 840 && griddleWide.copy.width <= 902 && griddleWide.media.width >= 1050 && griddleWide.media.width <= 1121 && griddleWide.media.width <= griddleWide.copy.width * 1.3, JSON.stringify(griddleWide)); pass("철판클럽 2560px 후속 콘텐츠 레일", griddleWide.contentRail.width >= 1200 && griddleWide.contentRail.width <= 1482 && griddleWide.contentRail.left >= 0 && griddleWide.contentRail.right <= 2560, JSON.stringify(griddleWide.contentRail)); const processCentered = Math.abs(griddleWide.process.rail.left + griddleWide.process.rail.width / 2 - 1280) <= 1; pass("철판클럽 2560px 과정 텍스트·이미지 분할", processCentered && griddleWide.process.rail.width >= 1678 && griddleWide.process.rail.width <= 1682 && griddleWide.process.copy.width >= 810 && griddleWide.process.copy.width <= 835 && griddleWide.process.media.width >= 840 && griddleWide.process.media.width <= 870 && griddleWide.process.heading.width >= 620 && griddleWide.process.headingLines <= 2, JSON.stringify(griddleWide.process)); // ── CSS 중괄호 균형 (v11 교훈) ── import fs2 from "node:fs"; for (const f of ["gaon-lms/styles/main.css", "dure-enrollment/styles/main.css", "gaon-lms/styles/tokens.css", "dure-enrollment/styles/tokens.css"]) { const c = fs2.readFileSync(path.join(root, "public/work", f), "utf8"); let depth = 0; for (const ch of c) { if (ch === "{") depth++; if (ch === "}") depth--; } pass(`CSS 중괄호 균형 ${f}`, depth === 0, `depth ${depth}`); } await browser.close(); console.log(results.join("\n")); const fails = results.filter((r) => r.startsWith("FAIL")).length; console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAIL`); process.exit(fails === 0 ? 0 : 1);