1160 lines
45 KiB
TypeScript
1160 lines
45 KiB
TypeScript
/* =====================================================================
|
||
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);
|
||
const googleLogin = page.getByRole("button", { name: /^Google 계정으로 로그인/ });
|
||
await expect(googleLogin).toHaveCount(1);
|
||
await page.keyboard.press("Tab");
|
||
await expect(googleLogin).toBeFocused();
|
||
const focusReport = await googleLogin.evaluate((element) => {
|
||
const target = element as HTMLElement;
|
||
const snapshot = () => {
|
||
const style = window.getComputedStyle(target);
|
||
return [
|
||
style.outlineStyle,
|
||
style.outlineWidth,
|
||
style.outlineColor,
|
||
style.boxShadow,
|
||
style.borderColor,
|
||
style.backgroundColor,
|
||
].join("|");
|
||
};
|
||
const focusVisible = target.matches(":focus-visible");
|
||
const focusedStyle = snapshot();
|
||
target.blur();
|
||
const blurredStyle = snapshot();
|
||
target.focus();
|
||
return { focusVisible, styleChanged: focusedStyle !== blurredStyle };
|
||
});
|
||
expect(focusReport.focusVisible).toBeTruthy();
|
||
expect(focusReport.styleChanged).toBeTruthy();
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
});
|