/* ===================================================================== uc-a11y-mobile.spec.ts — 접근성·모바일 레이아웃 유스케이스 감사. 목적: 주요 6화면(login, learn 홈, session active, session review, settings, teach 홈)을 실제 사용자 관점의 접근성 검사 6종으로 감사한다. (1) 가로 오버플로 0 (support.expectNoHorizontalOverflow) (2) 보이는 인터랙티브 요소 44×44px 이상 — 위반 목록을 실패 메시지로 리포트 (3) h1 헤딩 존재 + 헤딩 레벨 논리 순서(단계 건너뜀 없음) (4) 폼 컨트롤 라벨 연결(getByLabel 성립) (5) Tab 순회 시 :focus-visible 스타일 변화(포커스 표시) (6) 주요 CTA 색 대비 4.5:1 스팟체크(getComputedStyle 산술) 근거: - 각 화면의 실제 소스(src/pages/Login.tsx·LearnerHome.tsx·Session.tsx· SessionReview.tsx·Settings.tsx·Professor.tsx)의 role/aria-label/한국어 문구. - WCAG 2.1 1.4.3(대비), 2.4.6/1.3.1(헤딩·라벨), 2.4.7(포커스 가시성), 모바일 터치 타깃 44×44(WCAG 2.5.5 / 플랫폼 HIG). - fixture 형태는 full-sweep-session.spec.ts / full-sweep-learner-home.spec.ts / layout-visual-gate.spec.ts / e2e/session-review-fixture.ts 에서 차용. 중복 회피: breakpoint-sweep.spec.ts(@single-run)는 겹침·잘림·붕괴·화면밖·문서오버플로를 경계 폭 전수로, layout-visual-gate.spec.ts(@single-run)는 고정 7폭에서 오버플로·잘림을 검사한다. 이 스펙은 두 게이트가 다루지 않는 접근성 축 (타깃 크기, 헤딩 구조, 라벨, 포커스 링, 색 대비)을 두 실행 뷰포트 (chromium-desktop 1440×900 / chromium-mobile Pixel 5)에서 검사하고, 오버플로는 mocked-auth fixture 상태(두 게이트는 dev-login 실계정)라는 다른 각도에서만 재확인한다. 실 API 없이 route fixture 로만 동작하며, 실제 AI 엔진 턴 생성은 없다. ===================================================================== */ import { expect, test, type Locator, type Page } from "@playwright/test"; import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; /* ───────────────────────────────────────────────────────────────────── 공통 fixture — mocked-auth 패턴 (catch-all 먼저, 개별 fixture 나중) ───────────────────────────────────────────────────────────────────── */ function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } /** App.tsx 게이트(approved + consent + onboarding)가 보는 필드를 빠짐없이 채운다. */ function meBody(role: "learner" | "teacher") { return { user_id: role === "learner" ? "e2e-a11y-learner-1" : "e2e-a11y-teacher-1", email: `${role}@hs.ac.kr`, role, display_name: role === "learner" ? "학습자" : "교수자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["e2e-a11y-cohort"], // /auth/me 계약(api.gen.ts MeResponse)은 epoch 초(number|null)다 — ISO 문자열을 // 보내면 Settings 의 formatConsentDate(value * 1000)가 Invalid time value 로 죽는다. consent_at: Date.UTC(2026, 7, 1) / 1000, onboarding_completed_at: Date.UTC(2026, 7, 1) / 1000, nickname: role === "learner" ? "학습자" : "교수자", self_introduction: "", avatar_url: "", }; } async function routeAuthed(page: Page, role: "learner" | "teacher") { await page.route("**/auth/me", (route) => route.fulfill(jsonRoute(meBody(role)))); } /** 미인증 상태 + 로그인 설정 fixture — 로그인 화면 전용. */ async function routeLoginScreen(page: Page) { await routeUnmockedApi(page); await page.route("**/auth/me", (route) => route.fulfill(jsonRoute({ detail: "not authenticated" }, 401)), ); await page.route("**/auth/config", (route) => route.fulfill( jsonRoute({ allowed_email_domains: ["hs.ac.kr"], dev_login_enabled: false, google_oauth_configured: true, providers: [ { provider: "google", configured: true, enabled: true, login_path: "/auth/login" }, ], redirect_uri: "http://localhost:8000/api/auth/callback", saml_configured: false, }), ), ); } /* ── learn 홈 fixture — full-sweep-learner-home.spec.ts 형태 차용 ────── */ const NOW = Date.UTC(2026, 7, 18, 9, 0, 0); const HOUR = 60 * 60 * 1000; const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); const LEARN_ACT_ID = "uca11yact01"; const LEARN_PERSONAS = [ { code: "P1", display_name: "성하늘", difficulty: "easy", theory_target: ["humanistic"], demographics: { age_band: "20대" }, presenting_summary: "학업 스트레스와 수면 문제를 호소", voice_preset: null, source: "database", degraded: false, }, { code: "P2", display_name: "김도윤", difficulty: "moderate", theory_target: ["cbt"], demographics: { age_band: "30대" }, presenting_summary: "직장 내 대인관계 갈등을 호소", voice_preset: null, source: "database", degraded: false, }, ]; const LEARN_S_ACT = { session_id: LEARN_ACT_ID, session_no: 2, persona_code: "P1", persona_name: "성하늘", status: "active", stage: "탐색", started_at: iso(1 * HOUR), ended_at: null, review_ready: false, archived: false, archived_at: null, turn_count: 6, learner_turn_count: 3, client_turn_count: 3, }; const LEARN_S_REV = { session_id: "uca11yrev01", session_no: 1, persona_code: "P2", persona_name: "김도윤", status: "ended", stage: "정리", started_at: iso(3 * HOUR), ended_at: iso(2.5 * HOUR), review_ready: true, archived: false, archived_at: null, turn_count: 10, learner_turn_count: 5, client_turn_count: 5, }; const LEARN_DASHBOARD = { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 2, active_sessions: 1, review_ready_sessions: 1, completed_sessions: 1, archived_sessions: 0, learner_turns: 8, client_turns: 8, last_practiced_at: iso(1 * HOUR), }, growth: { trend: "up", avg_rapport: 0.4, avg_score: 0.6, evaluated_sessions: 1, latest_score: 0.6, first_score: 0.6, score_delta: 0, top_techniques: ["감정 반영"], points: [ { session_id: LEARN_S_REV.session_id, session_no: 1, persona_code: "P2", stage: "정리", started_at: LEARN_S_REV.started_at, ended_at: LEARN_S_REV.ended_at, score: 0.6, rapport: 0.4, technique_count: 3, watch_count: 1, }, ], }, recent_feedback: [ { session_id: LEARN_S_REV.session_id, session_no: 1, persona_code: "P2", persona_name: "김도윤", stage: "정리", score: 0.6, rapport: 0.4, note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.", created_at: LEARN_S_REV.ended_at, turn_seq: 6, techniques: ["감정 반영"], }, ], persona_progress: [ { persona_code: "P1", persona_name: "성하늘", sessions: 1, active_sessions: 1, completed_sessions: 0, review_ready_sessions: 0, rapport_percent: 52, trend: "insufficient", latest_stage: "탐색", latest_score: null, latest_at: iso(1 * HOUR), }, ], achievements: [ { id: "first-session", label: "첫 회기 완료", detail: "첫 회기를 끝냈습니다.", state: "done" }, ], }; const LEARN_DETAIL = { case_id: "case-uca11y", session_id: LEARN_ACT_ID, persona_code: "P1", persona_name: "성하늘", status: "active", stage: "탐색", started_at: iso(1 * HOUR), ended_at: null, review_ready: false, theory_mode: "humanistic", goal_stages: ["라포", "탐색"], effective_openness: 0.4, duration_limit_seconds: 1200, warning_before_end_seconds: 600, progress: null, turns: [ { turn_seq: 1, speaker: "learner", stage: "라포", text: "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?", created_at: iso(0.9 * HOUR), }, { turn_seq: 2, speaker: "client", stage: "라포", text: "요즘은 잠이 잘 안 와요.", created_at: iso(0.8 * HOUR), }, ], }; async function routeLearnerHome(page: Page) { await routeUnmockedApi(page); await routeAuthed(page, "learner"); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(LEARN_PERSONAS))); await page.route(/\/api\/sessions$/, (route) => route.fulfill(jsonRoute({ sessions: [LEARN_S_ACT, LEARN_S_REV], source: "runtime" })), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(LEARN_DASHBOARD)), ); await page.route(new RegExp(`/api/sessions/${LEARN_ACT_ID}$`), (route) => route.fulfill(jsonRoute(LEARN_DETAIL)), ); } /* ── session active fixture — full-sweep-session.spec.ts 형태 차용 ──── */ const SESSION_ID = "44444444-4444-4444-8444-4444a11e0001"; async function routeActiveSession(page: Page) { await routeUnmockedApi(page); await routeAuthed(page, "learner"); await page.route("**/api/personas", (route) => route.fulfill( jsonRoute([ { code: "P1", display_name: "민서(청소년 우울)", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "10대" }, presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습", voice_preset: "soft-young-fem", source: "database", degraded: false, }, ]), ), ); await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: true, reason: null })), ); await page.route("**/api/users/me/prepost-measures**", (route) => route.fulfill( jsonRoute({ pilot_id: "phase3-pilot-draft", instrument_version: "pilot-prepost-scaffold-2026-06-28", measures: [], complete_pre_count: 0, complete_post_count: 0, updated_at: null, }), ), ); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } await route.fulfill( jsonRoute( { session_id: SESSION_ID, case_id: "uc-a11y-case-001", session_no: 1, stage: "라포", effective_openness: 0.21, recall_summary: null, degraded: false, goal_stages: ["라포", "탐색"], duration_limit_seconds: 3600, warning_before_end_seconds: 600, }, 201, ), ); }); await page.route(`**/api/sessions/${SESSION_ID}`, (route) => route.fulfill( jsonRoute({ session_id: SESSION_ID, case_id: "uc-a11y-case-001", persona_code: "P1", persona_name: "민서", session_no: 1, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.21, started_at: new Date().toISOString(), ended_at: null, review_ready: false, turns: [], goal_stages: ["라포", "탐색"], progress: null, duration_limit_seconds: 3600, warning_before_end_seconds: 600, }), ), ); // pre 체크포인트가 이미 잠긴 상태로 응답해 알리안스 게이트를 fixture로 통과시킨다. await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, (route) => route.fulfill( jsonRoute({ items: [ { pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", checkpoint: "pre", status: "ready", learner_locked_at: new Date().toISOString(), revealed_at: new Date().toISOString(), error_code: null, self_scores: { goal: 0.5, task: 0.5, bond: 0.5 }, measurements: [], }, ], }), ), ); await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) => route.fulfill( jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [], }), ), ); } async function openActiveSession(page: Page) { await page.goto("/learn/session/P1"); const startButton = page.getByRole("button", { name: "회기 시작" }); await expect(startButton).toBeEnabled({ timeout: 15_000 }); await startButton.click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 }); } /* ── session review / settings / teach 홈 fixture ────────────────────── */ async function routeLearnerReview(page: Page) { await routeUnmockedApi(page); await routeAuthed(page, "learner"); // 나중 등록이 우선 — 리뷰·알리안스·사전사후 fixture 는 catch-all 뒤에 깐다. await routeFilledSessionReview(page); await routePrepostMeasures(page); } async function routeSettings(page: Page) { await routeUnmockedApi(page); await routeAuthed(page, "learner"); await page.route("**/api/users/me", (route) => route.fulfill( jsonRoute({ user_id: "e2e-a11y-learner-1", email: "learner@hs.ac.kr", role: "learner", display_name: "학습자", legal_name: "학습자", nickname: "학습자", affiliation: "한신대학교", department: "상담심리학과", grade_level: "3학년", phone: "010-0000-0000", contact_address: "경기도 오산시 한신대학교", self_introduction: "", avatar_url: "", cohort_ids: ["e2e-a11y-cohort"], onboarding_required: false, onboarding_completed_at: 1782820000, terms_agreed_at: 1782820000, terms_version: "2026-06", privacy_agreed_at: 1782820000, privacy_version: "2026-06", }), ), ); await page.route("**/api/users/me/preferences", (route) => route.fulfill( jsonRoute({ theme: "dark", voice_preset_id: "soft-young-fem", voice_rate: 1, notifications: { account_approval: true, learner_progress: false, product_news: false, safety_signal: true, session_done: true, }, }), ), ); await page.route("**/api/users/me/voice-presets", (route) => route.fulfill( jsonRoute([ { id: "soft-young-fem", name: "차분한 목소리", desc: "기본 내담자 음성", persona_hint: "P1", voice_id: "voice-1", }, ]), ), ); await page.route("**/api/users/support-tickets", (route) => route.fulfill( jsonRoute({ source: "database", durable: true, generated_at: 1782820000, tickets: [] }), ), ); } /** teach 홈 대시보드 — layout-visual-gate.spec.ts teacherAnalysisFixture 축약 차용. */ function teacherDashboardBody() { const learnerId = "uc-a11y-analysis-learner"; const sessions = Array.from({ length: 3 }, (_unused, index) => { const sessionNo = index + 1; return { session_id: `${learnerId}-session-${sessionNo}`, learner_id: learnerId, learner_label: "감사 검증 학습자", persona_code: "P1", persona_name: "서연", session_no: sessionNo, status: "ended", stage: sessionNo >= 3 ? "개입" : "탐색", turn_count: 8 + sessionNo, learner_turn_count: 4 + sessionNo, client_turn_count: 4, started_at: `2026-08-${String(10 + sessionNo).padStart(2, "0")}T09:00:00Z`, ended_at: `2026-08-${String(10 + sessionNo).padStart(2, "0")}T09:30:00Z`, review_status: "pending", review_note: null, reviewed_at: null, }; }); const points = sessions.map((session) => ({ session_id: session.session_id, session_no: session.session_no, persona_code: session.persona_code, stage: session.stage, started_at: session.started_at, ended_at: session.ended_at, score: session.session_no >= 3 ? 0.8 : 0.5, rapport: Math.min(0.8, session.session_no * 0.2), technique_count: session.session_no + 1, watch_count: session.session_no < 3 ? 1 : 0, })); const summary = { learner_id: learnerId, learner_label: "감사 검증 학습자", sessions: sessions.length, ended_sessions: sessions.length, latest_at: sessions[sessions.length - 1].ended_at ?? "", first_score: points[0].score, latest_score: points[points.length - 1].score, score_delta: points[points.length - 1].score - points[0].score, avg_score: 0.6, avg_rapport: 0.4, trend: "up", top_techniques: ["reflection"], points, }; return { source: "database", cohort_label: "a11y cohort", total_learners: 1, active_sessions: 0, ended_sessions: sessions.length, learner_growth: [summary], safety_alerts: [], pending_reviews: [], recent_sessions: sessions.slice(-2), message: "접근성 감사 fixture.", }; } async function routeTeachHome(page: Page) { await routeUnmockedApi(page); await routeAuthed(page, "teacher"); await page.route("**/api/teacher/dashboard", (route) => route.fulfill(jsonRoute(teacherDashboardBody())), ); } /* ───────────────────────────────────────────────────────────────────── 접근성 검사 헬퍼 (브라우저 컨텍스트 실측) ───────────────────────────────────────────────────────────────────── */ interface TapTargetViolation { element: string; width: number; height: number; } /** 보이는 인터랙티브 요소 중 44×44px 미만을 수집한다(비활성 컨트롤 제외). */ async function collectSmallTapTargets(page: Page): Promise { return page.evaluate(() => { const MIN = 44; const selector = "button, a[href], input:not([type='hidden']), select, textarea, " + "[role='button'], [role='tab'], [role='radio'], [role='checkbox']"; const out: Array<{ element: string; width: number; height: number }> = []; const seen = new Set(); for (const el of Array.from(document.querySelectorAll(selector))) { if (seen.has(el)) continue; seen.add(el); // 체크박스·라디오가