241 lines
8.8 KiB
TypeScript
241 lines
8.8 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
function jsonRoute(body: unknown, status = 200) {
|
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
|
}
|
|
|
|
const PROFILE = {
|
|
user_id: "e2e-settings-ui",
|
|
email: "settings.ui@hs.ac.kr",
|
|
display_name: "설정 화면 검증 학습자",
|
|
legal_name: "설정 화면 검증 학습자",
|
|
nickname: "설정검증",
|
|
self_introduction: "",
|
|
role: "learner",
|
|
cohort_ids: [],
|
|
affiliation: "한신대학교",
|
|
department: "상담심리학과",
|
|
grade_level: "3학년",
|
|
phone: "010-0000-0000",
|
|
contact_address: "경기도 오산시",
|
|
avatar_url: "",
|
|
onboarding_required: false,
|
|
onboarding_completed_at: 1780000000,
|
|
terms_version: "terms-draft-2026-06-27",
|
|
terms_agreed_at: 1780000000,
|
|
privacy_version: "privacy-draft-2026-06-27",
|
|
privacy_agreed_at: 1780000000,
|
|
};
|
|
|
|
const PREFERENCES = {
|
|
theme: "light",
|
|
voice_preset_id: "soft-young-fem",
|
|
voice_rate: 1,
|
|
notifications: {
|
|
session_done: true,
|
|
safety_signal: true,
|
|
learner_progress: true,
|
|
account_approval: true,
|
|
product_news: false,
|
|
},
|
|
};
|
|
|
|
const VOICE_PRESETS = [
|
|
{
|
|
id: "soft-young-fem",
|
|
name: "차분한 목소리",
|
|
voice_id: "ko-soft-01",
|
|
desc: "낮은 톤의 안정적인 발화",
|
|
persona_hint: "차분한 20대 내담자",
|
|
},
|
|
{
|
|
id: "bright-mid-fem",
|
|
name: "밝은 목소리",
|
|
voice_id: "ko-bright-02",
|
|
desc: "밝고 빠른 발화",
|
|
persona_hint: "활발한 30대 내담자",
|
|
},
|
|
];
|
|
|
|
async function mountSettingsFixture(page: Page) {
|
|
await page.route("**/api/**", (route) =>
|
|
route.fulfill(jsonRoute({ detail: "not part of settings UI fixture" }, 404)),
|
|
);
|
|
await page.route("**/api/auth/me", (route) =>
|
|
route.fulfill(jsonRoute({
|
|
user_id: PROFILE.user_id,
|
|
email: PROFILE.email,
|
|
role: "learner",
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: [],
|
|
consent_at: 1780000000,
|
|
onboarding_completed_at: PROFILE.onboarding_completed_at,
|
|
nickname: PROFILE.nickname,
|
|
display_name: PROFILE.display_name,
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
})),
|
|
);
|
|
await page.route("**/api/users/me", (route: Route) => {
|
|
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PROFILE));
|
|
return route.fulfill(jsonRoute(PROFILE));
|
|
});
|
|
await page.route("**/api/users/me/preferences", (route: Route) => {
|
|
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PREFERENCES));
|
|
return route.fulfill(jsonRoute(PREFERENCES));
|
|
});
|
|
await page.route("**/api/users/me/voice-presets", (route) => route.fulfill(jsonRoute(VOICE_PRESETS)));
|
|
await page.route("**/api/users/support-tickets", (route) =>
|
|
route.fulfill(jsonRoute({ source: "fixture", durable: false, generated_at: 1780000000, tickets: [] })),
|
|
);
|
|
}
|
|
|
|
async function expectNoHorizontalClipping(page: Page, width: number) {
|
|
const offenders = await page.locator(".vg-set, .vg-set *").evaluateAll((elements, viewportWidth) =>
|
|
elements.flatMap((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0) return [];
|
|
return rect.left < -0.5 || rect.right > Number(viewportWidth) + 0.5
|
|
? [{ tag: element.tagName, className: element.className, left: rect.left, right: rect.right }]
|
|
: [];
|
|
}), width,
|
|
);
|
|
expect(offenders).toEqual([]);
|
|
}
|
|
|
|
async function waitForScrollSettled(page: Page) {
|
|
await page.evaluate(async () => {
|
|
const scroller = document.querySelector(".vg-main");
|
|
await new Promise<void>((resolve) => {
|
|
let frame = 0;
|
|
let stableFrames = 0;
|
|
let previous = scroller?.scrollTop ?? window.scrollY;
|
|
const check = () => {
|
|
const current = scroller?.scrollTop ?? window.scrollY;
|
|
stableFrames = Math.abs(current - previous) < 0.5 ? stableFrames + 1 : 0;
|
|
previous = current;
|
|
frame += 1;
|
|
if (stableFrames >= 8 || frame >= 120) return resolve();
|
|
requestAnimationFrame(check);
|
|
};
|
|
requestAnimationFrame(check);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function expectMobileInteractiveTargets(page: Page) {
|
|
const undersized = await page
|
|
.locator(".vg-set button:not(:disabled), .vg-set [role='button']:not([aria-disabled='true'])")
|
|
.evaluateAll((elements) =>
|
|
elements.flatMap((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const selector = element.id
|
|
? `#${element.id}`
|
|
: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`;
|
|
return rect.width >= 44 && rect.height >= 44
|
|
? []
|
|
: [{ selector, text: element.textContent?.trim(), width: rect.width, height: rect.height }];
|
|
}),
|
|
);
|
|
expect(undersized).toEqual([]);
|
|
|
|
const voicePreview = await page.locator(".vg-set__voice-play").first().boundingBox();
|
|
expect(voicePreview?.width).toBeGreaterThanOrEqual(44);
|
|
expect(voicePreview?.height).toBeGreaterThanOrEqual(44);
|
|
}
|
|
|
|
test("설정 섹션 내비는 모든 화면 폭에서 라벨·터치 영역·탭 이동을 보장한다", async ({ page }) => {
|
|
await mountSettingsFixture(page);
|
|
const viewports = [
|
|
{ width: 320, height: 844 },
|
|
{ width: 390, height: 844 },
|
|
{ width: 768, height: 900 },
|
|
{ width: 1440, height: 900 },
|
|
];
|
|
const sections = [
|
|
["계정", "set-account"],
|
|
["지원 요청", "set-support"],
|
|
["동의", "set-consent"],
|
|
["테마", "set-appearance"],
|
|
["알림", "set-notify"],
|
|
["음성", "set-voice"],
|
|
] as const;
|
|
|
|
for (const viewport of viewports) {
|
|
await page.setViewportSize(viewport);
|
|
await page.goto("/settings");
|
|
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible();
|
|
|
|
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
|
const tabs = nav.getByRole("button");
|
|
await expect(tabs).toHaveCount(6);
|
|
await expectNoHorizontalClipping(page, viewport.width);
|
|
|
|
for (const [label, sectionId] of sections) {
|
|
const tab = nav.getByRole("button", { name: label });
|
|
await expect(tab).toBeVisible();
|
|
const box = await tab.boundingBox();
|
|
expect(box?.height).toBeGreaterThanOrEqual(44);
|
|
expect(box?.x).toBeGreaterThanOrEqual(0);
|
|
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(viewport.width);
|
|
|
|
await tab.click();
|
|
await waitForScrollSettled(page);
|
|
await expect(tab).toHaveAttribute("aria-current", "location");
|
|
await expect(page.locator(`#${sectionId}`)).toBeInViewport();
|
|
if (viewport.width <= 1080) {
|
|
const [navBox, sectionBox] = await Promise.all([
|
|
nav.boundingBox(),
|
|
page.locator(`#${sectionId}`).boundingBox(),
|
|
]);
|
|
expect(sectionBox?.y).toBeGreaterThanOrEqual((navBox?.y ?? 0) + (navBox?.height ?? 0));
|
|
}
|
|
if (viewport.width === 320 && label === "계정") {
|
|
const [railBox, accountBox] = await Promise.all([
|
|
page.locator(".vg-set__rail").boundingBox(),
|
|
page.locator("#set-account").boundingBox(),
|
|
]);
|
|
expect(railBox?.x).toBeCloseTo(accountBox?.x ?? 0, 0);
|
|
expect(railBox?.width).toBeCloseTo(accountBox?.width ?? 0, 0);
|
|
const railBackground = await page.locator(".vg-set__rail").evaluate(
|
|
(element) => getComputedStyle(element).backgroundColor,
|
|
);
|
|
expect(railBackground).not.toBe("rgba(0, 0, 0, 0)");
|
|
}
|
|
if (viewport.width === 320 && label === "지원 요청") {
|
|
const description = await page.locator("#set-support .vg-set__group-desc").boundingBox();
|
|
expect(description?.width).toBeGreaterThanOrEqual(150);
|
|
}
|
|
if (viewport.width === 320 && label === "동의") {
|
|
const [description, action] = await Promise.all([
|
|
page.locator("#set-consent .vg-set__opt-text").first().boundingBox(),
|
|
page.getByRole("button", { name: "실습 동의 철회" }).boundingBox(),
|
|
]);
|
|
expect(description?.width).toBeGreaterThanOrEqual(150);
|
|
expect(action?.height).toBeGreaterThanOrEqual(44);
|
|
}
|
|
await expectNoHorizontalClipping(page, viewport.width);
|
|
if (viewport.width === 320 && process.env.SETTINGS_UI_CAPTURE_DIR) {
|
|
await page.screenshot({
|
|
path: `${process.env.SETTINGS_UI_CAPTURE_DIR}/settings-320-${sectionId}.png`,
|
|
fullPage: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const account = nav.getByRole("button", { name: "계정" });
|
|
await account.focus();
|
|
await page.keyboard.press("Tab");
|
|
await expect(nav.getByRole("button", { name: "지원 요청" })).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect(nav.getByRole("button", { name: "지원 요청" })).toHaveAttribute("aria-current", "location");
|
|
await expect(page.locator("#set-support")).toBeInViewport();
|
|
|
|
if (viewport.width === 320) {
|
|
await expectMobileInteractiveTargets(page);
|
|
}
|
|
}
|
|
});
|