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