229 lines
9.4 KiB
TypeScript
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",
|
|
);
|
|
});
|