import { expect, test, type Page } from "@playwright/test"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expectNoHorizontalOverflow, signInAsTeacher } from "./support"; const OUTPUT_DIR = path.resolve( process.cwd(), "../../outputs/ux-audit-2026-09-12/teacher-targets", ); const VIEWPORTS = [ { width: 320, height: 844, label: "320" }, { width: 390, height: 844, label: "390" }, { width: 768, height: 900, label: "768" }, { width: 1440, height: 900, label: "1440" }, ] as const; type TargetMeasurement = { label: string; selector: string; viewport: number; x: number; y: number; width: number; height: number; right: number; scrollWidth: number; clientWidth: number; scrollHeight: number; clientHeight: number; clipped: boolean; }; type AnalysisMeasurement = { selector: string; x: number; width: number; right: number; clipped: boolean; }; async function measureTargets(page: Page, viewport: number): Promise { return page.locator(".pf-head__actions .vg-btn, .pf-studio-card__actions .vg-btn, .pf-sort-segment button, .pf-learner-row__action .vg-btn, .pf-session--action, .pf-recent-row--action").evaluateAll( (elements, currentViewport) => elements .map((element) => { const target = element as HTMLElement; const rect = target.getBoundingClientRect(); const style = window.getComputedStyle(target); const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; if (!visible) return null; return { label: target.textContent?.replace(/\s+/g, " ").trim() ?? "", selector: target.className, viewport: currentViewport, x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), right: Math.round(rect.right), scrollWidth: target.scrollWidth, clientWidth: target.clientWidth, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, clipped: rect.left < -1 || rect.right > window.innerWidth + 1 || target.scrollWidth > target.clientWidth + 1 || target.scrollHeight > target.clientHeight + 1, }; }) .filter((measurement): measurement is NonNullable => measurement !== null), viewport, ); } async function captureConsole(page: Page, label: string) { await page.screenshot({ path: path.join(OUTPUT_DIR, `console-top-${label}.png`) }); const pending = page.locator('[data-pending-review-row="true"]').first(); if (await pending.count()) { await pending.scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(OUTPUT_DIR, `console-pending-${label}.png`) }); } const recent = page.locator(".pf-recent-row--action").first(); if (await recent.count()) { await recent.scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(OUTPUT_DIR, `console-recent-${label}.png`) }); } } async function measureAnalysisSurface(page: Page): Promise { return page.locator( ".pf-root--analysis, .pf-head h1, .pf-head p, .pf-head__actions, .pf-section--learner-overview, .pf-learner-overview, .pf-learner-toolbar, .pf-search-field, .pf-sort-segment, .pf-learner-table", ).evaluateAll((elements) => elements.map((element) => { const target = element as HTMLElement; const rect = target.getBoundingClientRect(); return { selector: target.className || target.tagName.toLowerCase(), x: Math.round(rect.x), width: Math.round(rect.width), right: Math.round(rect.right), clipped: rect.left < -1 || rect.right > window.innerWidth + 1, }; }), ); } test("교수자 콘솔의 모바일 터치 대상은 44px이며 잘리지 않는다 @single-run", async ({ page }) => { test.setTimeout(180_000); await mkdir(OUTPUT_DIR, { recursive: true }); await signInAsTeacher(page); const evidence: TargetMeasurement[] = []; const analysisEvidence: Record[] = []; for (const viewport of VIEWPORTS) { await page.setViewportSize(viewport); await page.goto("/teach"); await expect(page.locator(".pf-root--console")).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("button", { name: "학생 분석" })).toBeVisible(); await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible(); await expect(page.locator(".pf-studio-card__actions .vg-btn")).toBeVisible(); await expectNoHorizontalOverflow(page); await captureConsole(page, viewport.label); const consoleTargets = await measureTargets(page, viewport.width); evidence.push(...consoleTargets); const clipped = consoleTargets.filter((target) => target.clipped); expect(clipped, `교수 콘솔 ${viewport.width}px 잘린 대상`).toEqual([]); if (viewport.width <= 720) { const undersized = consoleTargets.filter( (target) => target.width < 44 || target.height < 44, ); expect(undersized, `교수 콘솔 ${viewport.width}px 44px 미만 대상`).toEqual([]); } await page.goto("/teach/analysis"); await expect(page.locator(".pf-root--analysis")).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("heading", { name: "학생별 학습 현황" })).toBeVisible(); await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-top-${viewport.label}.png`) }); const analysisSurface = await measureAnalysisSurface(page); const outsideViewport = analysisSurface.filter((measurement) => measurement.clipped); expect(outsideViewport, `학생 분석 ${viewport.width}px 표면 범위`).toEqual([]); const table = page.locator(".pf-learner-table"); await expect(table).toBeVisible(); await table.evaluate((element) => { element.scrollLeft = 0; }); await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-left-${viewport.label}.png`) }); const firstColumn = table.locator(".pf-learner-table__head > span").first(); const tableBox = await table.boundingBox(); const firstColumnBox = await firstColumn.boundingBox(); expect(firstColumnBox, `학생 분석 ${viewport.width}px 표 왼쪽 열`).not.toBeNull(); expect(tableBox, `학생 분석 ${viewport.width}px 표 컨테이너`).not.toBeNull(); expect(firstColumnBox!.x).toBeGreaterThanOrEqual(tableBox!.x - 1); const tableBounds = await table.evaluate((element) => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, scrollLeft: element.scrollLeft, })); await table.evaluate((element) => { element.scrollLeft = element.scrollWidth - element.clientWidth; }); await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-right-${viewport.label}.png`) }); const tableEnd = await table.evaluate((element) => element.scrollLeft); const lastColumn = table.locator(".pf-learner-table__head > span").last(); const lastColumnBox = await lastColumn.boundingBox(); expect(lastColumnBox, `학생 분석 ${viewport.width}px 표 오른쪽 열`).not.toBeNull(); expect(lastColumnBox!.x + lastColumnBox!.width).toBeLessThanOrEqual( tableBox!.x + tableBox!.width + 1, ); if (tableBounds.scrollWidth > tableBounds.clientWidth) { expect(tableEnd, `학생 분석 ${viewport.width}px 표 오른쪽 끝`).toBeGreaterThan(0); } analysisEvidence.push({ viewport: viewport.width, elements: analysisSurface, table: { ...tableBounds, endScrollLeft: tableEnd }, }); const analysisTargets = await measureTargets(page, viewport.width); evidence.push(...analysisTargets); const analysisClipped = analysisTargets.filter((target) => target.clipped); expect(analysisClipped, `학생 분석 ${viewport.width}px 잘린 대상`).toEqual([]); if (viewport.width <= 720) { const undersized = analysisTargets.filter( (target) => target.width < 44 || target.height < 44, ); expect(undersized, `학생 분석 ${viewport.width}px 44px 미만 대상`).toEqual([]); const openDetail = page.locator(".pf-learner-row__action .vg-btn").first(); await expect(openDetail).toBeVisible(); await openDetail.click(); const backToList = page.getByRole("button", { name: "전체 목록" }); await expect(backToList).toBeVisible({ timeout: 15_000 }); await expectNoHorizontalOverflow(page); const backToListBox = await backToList.boundingBox(); expect(backToListBox, `학생 분석 상세 ${viewport.width}px 전체 목록`).not.toBeNull(); expect(backToListBox!.width).toBeGreaterThanOrEqual(44); expect(backToListBox!.height).toBeGreaterThanOrEqual(44); await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-detail-${viewport.label}.png`), }); } } await writeFile( path.join(OUTPUT_DIR, "target-measurements.json"), `${JSON.stringify(evidence, null, 2)}\n`, "utf8", ); await writeFile( path.join(OUTPUT_DIR, "analysis-measurements.json"), `${JSON.stringify(analysisEvidence, null, 2)}\n`, "utf8", ); });