vignette/apps/web/e2e/uc-a11y-mobile.spec.ts

1135 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =====================================================================
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: [],
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<TapTargetViolation[]> {
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<Element>();
for (const el of Array.from(document.querySelectorAll<HTMLElement>(selector))) {
if (seen.has(el)) continue;
seen.add(el);
// 체크박스·라디오가 <label> 안에 있으면 실제 활성화 타깃은 라벨 전체다
// (라벨 클릭이 컨트롤을 토글한다 — WCAG 2.5.5 의 "타깃"은 이 영역).
// 시각적으로 숨긴 1×1 clip 패턴 입력도 스타일된 라벨이 타깃을 대표한다.
let target: HTMLElement = el;
if (
el instanceof HTMLInputElement &&
(el.type === "checkbox" || el.type === "radio")
) {
const wrappingLabel = el.closest("label");
if (wrappingLabel) target = wrappingLabel;
}
const style = window.getComputedStyle(target);
const rect = target.getBoundingClientRect();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0 &&
!el.closest("[hidden]") &&
!el.closest("[aria-hidden='true']") &&
!el.closest("[inert]");
if (!visible) continue;
if ((el as HTMLButtonElement).disabled) continue;
if (rect.width + 0.5 >= MIN && rect.height + 0.5 >= MIN) continue;
const cls = String(el.className || "")
.trim()
.split(/\s+/)
.slice(0, 2)
.join(".");
const text = (el.textContent ?? el.getAttribute("aria-label") ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 40);
out.push({
element: `${el.tagName.toLowerCase()}${cls ? `.${cls}` : ""}${text ? ` "${text}"` : ""}`,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
return out;
});
}
async function expectTapTargets44(
page: Page,
screen: string,
options: { wideCompactAllowlist?: string[] } = {},
) {
let violations = await collectSmallTapTargets(page);
// 데스크톱(>720px)은 문서화된 밀도 계약을 가진 컴팩트 컨트롤을 허용한다
// (예: 리뷰 필터 칩 — session-review.spec.ts 폭 분기 계약이 데스크톱 ≤34px,
// 모바일 ≥44px 을 소유). 모바일 뷰포트에서는 허용 없이 44px 을 강제한다.
const viewportWidth = page.viewportSize()?.width ?? 0;
const allowlist = options.wideCompactAllowlist ?? [];
if (viewportWidth > 720 && allowlist.length > 0) {
violations = violations.filter(
(violation) => !allowlist.some((cls) => violation.element.includes(cls)),
);
}
expect(
violations,
`[${screen}] 44×44px 미만 인터랙티브 요소 ${violations.length}건:\n` +
violations
.slice(0, 20)
.map((v) => ` · ${v.element}${v.width}×${v.height}px`)
.join("\n") +
(violations.length > 20 ? `\n · … 외 ${violations.length - 20}` : ""),
).toEqual([]);
}
interface HeadingReport {
h1Count: number;
sequence: Array<{ level: number; text: string }>;
skips: Array<{ from: number; to: number; text: string }>;
}
/** 보이는 헤딩(h1~h6)의 레벨 시퀀스와 "한 단계 초과 하강" 위반을 수집한다. */
async function collectHeadingReport(page: Page): Promise<HeadingReport> {
return page.evaluate(() => {
const headings = Array.from(
document.querySelectorAll<HTMLElement>("h1, h2, h3, h4, h5, h6"),
).filter((el) => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0 &&
!el.closest("[hidden]") &&
!el.closest("[aria-hidden='true']")
);
});
const sequence = headings.map((el) => ({
level: Number(el.tagName.slice(1)),
text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 50),
}));
const skips: Array<{ from: number; to: number; text: string }> = [];
let previous = 0;
for (const item of sequence) {
if (previous > 0 && item.level > previous + 1) {
skips.push({ from: previous, to: item.level, text: item.text });
}
previous = item.level;
}
return { h1Count: sequence.filter((item) => item.level === 1).length, sequence, skips };
});
}
async function expectHeadingStructure(page: Page, screen: string) {
const report = await collectHeadingReport(page);
expect(
report.h1Count,
`[${screen}] 화면에 h1 이 정확히 1개 있어야 한다. 실제 헤딩 시퀀스: ${JSON.stringify(report.sequence)}`,
).toBe(1);
expect(
report.sequence[0]?.level,
`[${screen}] 첫 헤딩은 h1 이어야 한다. 실제: ${JSON.stringify(report.sequence[0])}`,
).toBe(1);
expect(
report.skips,
`[${screen}] 헤딩 레벨이 한 단계를 건너뛰었다: ${JSON.stringify(report.skips)}`,
).toEqual([]);
}
interface FocusStop {
element: string;
focusVisible: boolean;
styleChanged: boolean;
}
/**
* Tab 을 눌러 포커스를 순회하며, :focus-visible 상태의 요소가
* 블러 상태 대비 시각적 스타일(outline/box-shadow/border/background) 변화를
* 실제로 보이는지 확인한다.
*/
async function collectFocusStops(page: Page, presses: number): Promise<FocusStop[]> {
const stops: FocusStop[] = [];
for (let i = 0; i < presses; i += 1) {
await page.keyboard.press("Tab");
const stop = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
if (!el || el === document.body || el === document.documentElement) return null;
const snapshot = () => {
const s = window.getComputedStyle(el);
return [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.backgroundColor].join("|");
};
const focusVisible = el.matches(":focus-visible");
const focusedStyle = snapshot();
el.blur();
const blurredStyle = snapshot();
el.focus();
const cls = String(el.className || "").trim().split(/\s+/).slice(0, 2).join(".");
const text = (el.textContent ?? el.getAttribute("aria-label") ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 40);
return {
element: `${el.tagName.toLowerCase()}${cls ? `.${cls}` : ""}${text ? ` "${text}"` : ""}`,
focusVisible,
styleChanged: focusedStyle !== blurredStyle,
};
});
if (stop) stops.push(stop);
}
return stops;
}
async function expectFocusVisibleOnTab(page: Page, screen: string, presses = 6) {
const stops = await collectFocusStops(page, presses);
const interactiveStops = stops.filter((stop) => stop.focusVisible);
expect(
interactiveStops.length,
`[${screen}] Tab ${presses}회 순회에서 :focus-visible 포커스 정지점을 찾지 못했다. 정지점: ${JSON.stringify(stops)}`,
).toBeGreaterThanOrEqual(2);
const unmarked = interactiveStops.filter((stop) => !stop.styleChanged);
expect(
unmarked,
`[${screen}] :focus-visible 인데 시각적 포커스 표시(스타일 변화)가 없는 요소: ${JSON.stringify(unmarked)}`,
).toEqual([]);
}
/** WCAG 상대 휘도·대비 산술 — 조상 배경을 알파 합성해 유효 배경색을 구한다. */
async function ctaContrastRatio(target: Locator): Promise<{
ratio: number;
threshold: number;
color: string;
background: string;
}> {
return target.evaluate((el) => {
// 색 전이(transition) 중간값이 아니라 안착값을 계약으로 측정한다 —
// vg-btn 은 background 에 var(--dur-base) 전이가 걸려 있어 상태 변경 직후
// 스냅샷이 중간 색을 읽는다. transition 을 끊으면 계산값이 목표값으로 즉시 안착한다.
let node: HTMLElement | null = el as HTMLElement;
while (node && node !== document.documentElement) {
node.style.transitionProperty = "none";
node = node.parentElement;
}
void (el as HTMLElement).offsetWidth;
function parseColor(value: string): [number, number, number, number] {
const match = value.match(/rgba?\(([^)]+)\)/);
if (!match) return [0, 0, 0, 1];
const parts = match[1].split(",").map((part) => Number.parseFloat(part.trim()));
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, parts[3] ?? 1];
}
function composite(
fg: [number, number, number, number],
bg: [number, number, number],
): [number, number, number] {
const a = fg[3];
return [
fg[0] * a + bg[0] * (1 - a),
fg[1] * a + bg[1] * (1 - a),
fg[2] * a + bg[2] * (1 - a),
];
}
/** 그라디언트 background-image 의 색 정지점들을 추출한다(url 레이어는 무시). */
function gradientStops(backgroundImage: string): Array<[number, number, number, number]> {
if (!backgroundImage || backgroundImage === "none" || !backgroundImage.includes("gradient")) {
return [];
}
const stops: Array<[number, number, number, number]> = [];
for (const match of backgroundImage.matchAll(/rgba?\([^)]+\)/g)) {
stops.push(parseColor(match[0]));
}
return stops;
}
/**
* 유효 배경 후보들 — 조상 배경색을 알파 합성하되, 그라디언트(background-image)가
* 있으면 각 색 정지점을 별도 후보로 전개한다. 대비는 후보 중 최악값으로 판정한다
* (vg-btn--primary 처럼 배경이 그라디언트뿐이면 backgroundColor 는 투명이라
* 조상 배경으로 오판된다 — 실제 계약은 그라디언트 위 텍스트다).
*/
function effectiveBackgrounds(start: Element): Array<[number, number, number]> {
const layers: Array<{
color: [number, number, number, number];
stops: Array<[number, number, number, number]>;
}> = [];
let node: Element | null = start;
while (node && node !== document.documentElement) {
const nodeStyle = window.getComputedStyle(node);
const color = parseColor(nodeStyle.backgroundColor);
const stops = gradientStops(nodeStyle.backgroundImage);
if (color[3] > 0 || stops.length > 0) {
layers.push({ color, stops });
const opaqueColor = color[3] >= 1;
const opaqueGradient = stops.length > 0 && stops.every((stop) => stop[3] >= 1);
if (opaqueColor || opaqueGradient) break;
}
node = node.parentElement;
}
let candidates: Array<[number, number, number]> = [[255, 255, 255]];
const rootBg = parseColor(window.getComputedStyle(document.documentElement).backgroundColor);
if (rootBg[3] > 0) candidates = [[rootBg[0], rootBg[1], rootBg[2]]];
for (let i = layers.length - 1; i >= 0; i -= 1) {
const layer = layers[i];
// background-color 가 먼저 깔리고 그 위에 background-image(그라디언트)가 얹힌다.
const bases = candidates.map((candidate) =>
layer.color[3] > 0 ? composite(layer.color, candidate) : candidate,
);
candidates =
layer.stops.length > 0
? bases.flatMap((base) => layer.stops.map((stop) => composite(stop, base)))
: bases;
if (candidates.length > 16) candidates = candidates.slice(0, 16);
}
return candidates;
}
function luminance([r, g, b]: [number, number, number]) {
const channel = (v: number) => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
const style = window.getComputedStyle(el);
const fg = parseColor(style.color);
// 배경 후보(그라디언트 정지점 포함) 중 최악 대비를 판정값으로 쓴다.
let ratio = Number.POSITIVE_INFINITY;
let bg: [number, number, number] = [255, 255, 255];
for (const candidate of effectiveBackgrounds(el)) {
const text: [number, number, number] = composite(fg, candidate);
const lighter = Math.max(luminance(text), luminance(candidate));
const darker = Math.min(luminance(text), luminance(candidate));
const candidateRatio = (lighter + 0.05) / (darker + 0.05);
if (candidateRatio < ratio) {
ratio = candidateRatio;
bg = candidate;
}
}
const fontSize = Number.parseFloat(style.fontSize) || 16;
const bold = Number.parseInt(style.fontWeight, 10) >= 700;
const isLargeText = fontSize >= 24 || (bold && fontSize >= 18.66);
return {
ratio: Math.round(ratio * 100) / 100,
threshold: isLargeText ? 3 : 4.5,
color: style.color,
background: `rgb(${bg.map((v) => Math.round(v)).join(", ")})`,
};
});
}
async function expectCtaContrast(target: Locator, screen: string, label: string) {
await expect(target).toBeVisible();
const report = await ctaContrastRatio(target);
expect(
report.ratio,
`[${screen}] CTA "${label}" 색 대비 ${report.ratio}:1 (${report.color} on ${report.background}) — 기준 ${report.threshold}:1 미달`,
).toBeGreaterThanOrEqual(report.threshold);
}
/* ─────────────────────────────────────────────────────────────────────
화면 진입 헬퍼
───────────────────────────────────────────────────────────────────── */
async function openLogin(page: Page) {
await routeLoginScreen(page);
await page.goto("/login");
await expect(page.locator(".lg-root, .lg-enter").first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /^Google 계정으로 계속/ })).toBeVisible();
}
async function openLearnerHome(page: Page) {
await routeLearnerHome(page);
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible({ timeout: 15_000 });
}
async function openLearnerReview(page: Page) {
await routeLearnerReview(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("tablist", { name: "리뷰 영역 선택" })).toBeVisible();
}
async function openSettings(page: Page) {
await routeSettings(page);
await page.goto("/settings");
await expect(page.locator(".vg-set")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 });
}
async function openTeachHome(page: Page) {
await routeTeachHome(page);
await page.goto("/teach");
await expect(page.locator(".pf-root")).toBeVisible({ timeout: 15_000 });
}
/* ─────────────────────────────────────────────────────────────────────
테스트 — 화면 × 검사 조합
───────────────────────────────────────────────────────────────────── */
test.describe("uc a11y-mobile — 주요 6화면 접근성·모바일 감사", () => {
// 감사 기준면을 결정적으로 만든다 — 저장값 우선 규칙(theme store)을 그대로 사용.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
try {
localStorage.setItem("vignette.theme", "dark");
} catch {
/* storage 접근 불가 환경에서는 앱 폴백에 맡긴다 */
}
});
});
/* ── login ─────────────────────────────────────────────────────────── */
// usecase: 방문자가 로그인 화면을 열어도 화면이 가로로 넘치지 않는다
test("로그인 화면을 열면 어떤 뷰포트에서도 가로 스크롤 없이 전체가 보인다", async ({ page }) => {
await openLogin(page);
await expectNoHorizontalOverflow(page);
});
// usecase: 스크린리더 사용자가 로그인 화면의 헤딩 구조로 내용을 훑는다
test("로그인 화면은 h1 하나로 시작하는 논리적 헤딩 구조를 가진다", async ({ page }) => {
await openLogin(page);
await expectHeadingStructure(page, "login");
});
// usecase: 키보드 사용자가 Tab 으로 로그인 버튼에 도달하고 포커스 위치를 눈으로 확인한다
test("로그인 화면에서 Tab 순회 시 포커스 표시가 눈에 보인다", async ({ page }) => {
await openLogin(page);
await expectFocusVisibleOnTab(page, "login", 5);
});
// usecase: 저시력 사용자가 주 로그인 버튼의 문구를 읽는다
test("로그인 주 CTA(Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
await openLogin(page);
await expectCtaContrast(
page.getByRole("button", { name: /^Google 계정으로 계속/ }),
"login",
"Google 계정으로 계속",
);
});
// usecase: 모바일 방문자가 손가락으로 로그인 버튼을 정확히 누른다
test("로그인 화면의 인터랙티브 요소가 44×44px 터치 타깃을 지킨다", async ({ page }) => {
await openLogin(page);
await expectTapTargets44(page, "login");
});
/* ── learn 홈 ──────────────────────────────────────────────────────── */
// usecase: 학습자가 홈에 들어와 탭을 전환해도 화면이 가로로 넘치지 않는다
test("학습자 홈은 대시보드 탭을 전환해도 가로 오버플로가 없다", async ({ page }) => {
await openLearnerHome(page);
await expectNoHorizontalOverflow(page);
// 중간 폭 이하 전용 가로 탭 — 보이는 뷰포트(모바일)에서만 전환해 재검사한다.
const historyTab = page.getByRole("tab", { name: "기록 · 리뷰" });
if (await historyTab.isVisible().catch(() => false)) {
await historyTab.click();
await expectNoHorizontalOverflow(page);
await page.getByRole("tab", { name: "오늘의 회기" }).click();
await expectNoHorizontalOverflow(page);
}
});
// usecase: 스크린리더 사용자가 학습자 홈의 헤딩으로 오늘 할 일을 파악한다
test("학습자 홈은 h1 하나로 시작하는 논리적 헤딩 구조를 가진다", async ({ page }) => {
await openLearnerHome(page);
await expectHeadingStructure(page, "learner-home");
});
// usecase: 모바일 학습자가 회기 카드·탭 버튼을 손가락으로 누른다
test("학습자 홈의 인터랙티브 요소가 44×44px 터치 타깃을 지킨다", async ({ page }) => {
await openLearnerHome(page);
await expectTapTargets44(page, "learner-home");
});
/* ── session active ────────────────────────────────────────────────── */
// usecase: 학습자가 회기를 시작해 대화 화면을 쓰는 동안 화면이 가로로 넘치지 않는다
test("진행 중 회기 화면은 가로 오버플로 없이 전부 담긴다", async ({ page }) => {
await routeActiveSession(page);
await openActiveSession(page);
await expectNoHorizontalOverflow(page);
});
// usecase: 보조기기 사용자가 발화 입력창을 라벨로 찾아 입력한다
test("발화 입력창이 라벨(학습자 발화 입력)로 연결되어 입력이 된다", async ({ page }) => {
await routeActiveSession(page);
await openActiveSession(page);
const composer = page.getByLabel("학습자 발화 입력");
await expect(composer).toBeVisible();
await expect(composer).toBeEnabled();
await composer.fill("오늘 이야기를 시작해 볼까요?");
await expect(composer).toHaveValue("오늘 이야기를 시작해 볼까요?");
// 입력이 생기면 보내기 CTA 가 활성화된다 — 라벨-입력-CTA 흐름이 성립한다.
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
});
// usecase: 저시력 학습자가 보내기 버튼의 문구를 읽고 발화를 전송한다
test("회기 화면 보내기 CTA 의 색 대비가 4.5:1 이상이다", async ({ page }) => {
await routeActiveSession(page);
await openActiveSession(page);
await page.getByLabel("학습자 발화 입력").fill("대비 검사용 발화");
await expectCtaContrast(page.getByRole("button", { name: "보내기" }), "session-active", "보내기");
});
// usecase: 모바일 학습자가 회기 화면의 마이크·보내기·모드 버튼을 손가락으로 누른다
test("진행 중 회기 화면의 인터랙티브 요소가 44×44px 터치 타깃을 지킨다", async ({ page }) => {
await routeActiveSession(page);
await openActiveSession(page);
await expectTapTargets44(page, "session-active");
});
/* ── session review ────────────────────────────────────────────────── */
// usecase: 학습자가 리뷰 화면에서 피드백·축어록 탭을 오가며 읽어도 화면이 넘치지 않는다
test("회기 리뷰 화면은 리뷰 탭을 전환해도 가로 오버플로가 없다", async ({ page }) => {
await openLearnerReview(page);
await expectNoHorizontalOverflow(page);
await page.getByRole("tab", { name: "피드백" }).click();
await expectNoHorizontalOverflow(page);
await page.getByRole("tab", { name: "축어록" }).click();
await expectNoHorizontalOverflow(page);
});
// usecase: 스크린리더 사용자가 리뷰 화면의 헤딩으로 평가 결과를 훑는다
test("회기 리뷰 화면은 h1 하나로 시작하는 논리적 헤딩 구조를 가진다", async ({ page }) => {
await openLearnerReview(page);
await expectHeadingStructure(page, "session-review");
});
// usecase: 모바일 학습자가 리뷰 탭과 발화 점프 버튼을 손가락으로 누른다
test("회기 리뷰 화면의 인터랙티브 요소가 44×44px 터치 타깃을 지킨다", async ({ page }) => {
await openLearnerReview(page);
await expectTapTargets44(page, "session-review", {
wideCompactAllowlist: ["sr-chip-toggle"],
});
});
/* ── settings ──────────────────────────────────────────────────────── */
// usecase: 사용자가 설정 화면을 열어도 화면이 가로로 넘치지 않는다
test("설정 화면은 가로 오버플로 없이 전부 담긴다", async ({ page }) => {
await openSettings(page);
await expectNoHorizontalOverflow(page);
});
// usecase: 보조기기 사용자가 계정 입력 필드를 라벨로 찾아 수정한다
test("설정 계정 입력들이 라벨(표시 이름·이메일·소속)로 연결된다", async ({ page }) => {
await openSettings(page);
const displayName = page.getByLabel("표시 이름");
await expect(displayName).toBeVisible();
await displayName.fill("접근성 감사자");
await expect(displayName).toHaveValue("접근성 감사자");
await expect(page.getByLabel("이메일")).toHaveValue("learner@hs.ac.kr");
const affiliation = page.getByLabel("소속");
await expect(affiliation).toBeVisible();
await expect(affiliation).toBeEditable();
});
// usecase: 스크린리더 사용자가 설정 화면의 헤딩으로 섹션을 파악한다
test("설정 화면은 h1 하나로 시작하는 논리적 헤딩 구조를 가진다", async ({ page }) => {
await openSettings(page);
await expectHeadingStructure(page, "settings");
});
// usecase: 저시력 사용자가 알림 저장 버튼의 문구를 읽고 설정을 저장한다
test("설정 알림 저장 CTA 의 색 대비가 4.5:1 이상이다", async ({ page }) => {
await openSettings(page);
await expectCtaContrast(page.getByRole("button", { name: "알림 저장" }), "settings", "알림 저장");
});
/* ── teach 홈 ──────────────────────────────────────────────────────── */
// usecase: 교수자가 대시보드를 열어도 화면이 가로로 넘치지 않는다
test("교수자 홈은 가로 오버플로 없이 전부 담긴다", async ({ page }) => {
await openTeachHome(page);
await expectNoHorizontalOverflow(page);
});
// usecase: 스크린리더를 쓰는 교수자가 헤딩으로 검토 대기 현황을 파악한다
test("교수자 홈은 h1 하나로 시작하는 논리적 헤딩 구조를 가진다", async ({ page }) => {
await openTeachHome(page);
await expectHeadingStructure(page, "teach-home");
});
// usecase: 키보드를 쓰는 교수자가 Tab 으로 대시보드 액션에 도달하고 포커스를 확인한다
test("교수자 홈에서 Tab 순회 시 포커스 표시가 눈에 보인다", async ({ page }) => {
await openTeachHome(page);
await expectFocusVisibleOnTab(page, "teach-home", 8);
});
});