vignette/apps/web/e2e/teacher-mobile-targets.spec.ts

229 lines
9.4 KiB
TypeScript

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<TargetMeasurement[]> {
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<typeof measurement> => 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<AnalysisMeasurement[]> {
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<string, unknown>[] = [];
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",
);
});