vignette/apps/web/e2e/settings-ui.spec.ts

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);
}
}
});