// 2026-08-18 유스케이스 E2E — 학습자 홈 대시보드(learner-home-dashboard) 사용 여정. // 목적: 학습자가 대시보드에서 현황을 읽고(메트릭·라포 게이지), 추천/이어하기/새 회기 // 시작으로 이동하고, 빈 대시보드·대량 기록·키보드 조작까지 실제 사용 행동 단위로 검증한다. // 근거: src/pages/LearnerHome.tsx, src/pages/learner-home/model.ts, // src/lib/personaViewModel.ts, src/lib/format.ts 의 실제 UI 계약. // 인증·데이터는 전부 route fixture(mocked-auth 패턴)로 고정하며, // 실제 AI 엔진 턴 생성은 일절 수행하지 않는다. // 중복 회피: full-sweep-learner-home.spec.ts(메트릭 4카드 populated 값·리뷰 우선 추천· // 스포트라이트 리캡·기록 필터/검색·보관·결정 D3)와 learner.spec.ts(실 API 레이아웃)는 // 이미 커버 — 여기서는 평가 0회 메트릭, 리뷰 0건 추천 분기, 게이지 바 폭, 컴팩트 행 // 이동, 새 회기 시작 경로, 50건 대량 렌더, 신규 빈 상태, 키보드 여정을 다룬다. import { expect, test, type Page } from "@playwright/test"; import { expectNoHorizontalOverflow } from "./support"; const NOW = Date.now(); const HOUR = 60 * 60 * 1000; const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); const ACT_ID = "uclhact1"; const END_ID = "uclhend1"; 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)), ); } async function routeAuthedLearner(page: Page) { await page.route("**/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "e2e-uc-learner-1", email: "learner@hs.ac.kr", role: "learner", display_name: "학습자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "학습자", self_introduction: "", avatar_url: "", }), ), ); } // P1 기초(사용 가능) / P2 중간(사용 가능) / P3 고난도(degraded → 사용 불가) const PERSONAS_BODY = [ { 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, }, { code: "P3", display_name: "이바다", difficulty: "hard", theory_target: ["psychodynamic"], demographics: { age_band: "40대" }, presenting_summary: "만성적인 무기력을 호소", voice_preset: null, source: "database", degraded: true, }, ]; // 진행 중 1건(P1) + 종료(리뷰 없음) 1건(P2) — 리뷰 대기 0건 상태의 기본 세트. const S_ACT = { session_id: 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 S_END = { session_id: END_ID, session_no: 1, persona_code: "P2", persona_name: "김도윤", status: "ended", stage: "라포", started_at: iso(5 * HOUR), ended_at: iso(4.5 * HOUR), review_ready: false, archived: false, archived_at: null, turn_count: 4, learner_turn_count: 2, client_turn_count: 2, }; const BASE_SESSIONS = [S_ACT, S_END]; const TRAINING_EXPOSURE_CONTRACT = { version: "training-exposure-dominant-share.v1", minimum_completed_sessions: 4, attention_threshold: 0.75, definition: "종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.", }; // 평가가 아직 없는 학습자 — 리뷰 0건, 평가 0회, 라포 평균 null. const DASH_NO_EVAL = { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 2, active_sessions: 1, review_ready_sessions: 0, completed_sessions: 1, archived_sessions: 0, learner_turns: 5, client_turns: 5, last_practiced_at: iso(1 * HOUR), }, growth: { trend: "insufficient", avg_rapport: null, avg_score: null, evaluated_sessions: 0, latest_score: null, first_score: null, score_delta: null, top_techniques: [], points: [], }, recent_feedback: [], persona_progress: [ { persona_code: "P1", persona_name: "성하늘", sessions: 1, active_sessions: 1, completed_sessions: 0, review_ready_sessions: 0, rapport_percent: 0, trend: "insufficient", latest_stage: "탐색", latest_score: null, latest_at: iso(1 * HOUR), }, { persona_code: "P2", persona_name: "김도윤", sessions: 1, active_sessions: 0, completed_sessions: 1, review_ready_sessions: 0, rapport_percent: 0, trend: "insufficient", latest_stage: "라포", latest_score: null, latest_at: iso(4.5 * HOUR), }, ], achievements: [], training_exposure: { ...TRAINING_EXPOSURE_CONTRACT, status: "insufficient", label: "판정 근거 부족", completed_sessions: 1, dominant_persona_code: "P2", dominant_persona_name: "김도윤", dominant_sessions: 1, dominant_share: 1, }, }; // 라포 게이지 검증용 — 서로 다른 rapport_percent(81/47)를 가진 진행 기록. const DASH_RAPPORT = { ...DASH_NO_EVAL, growth: { ...DASH_NO_EVAL.growth, trend: "up", avg_rapport: 0.4, avg_score: 0.7, evaluated_sessions: 2, latest_score: 0.7, }, persona_progress: [ { ...DASH_NO_EVAL.persona_progress[0], sessions: 3, completed_sessions: 2, rapport_percent: 81, trend: "up", }, { ...DASH_NO_EVAL.persona_progress[1], rapport_percent: 47, }, ], }; const EMPTY_DASHBOARD = { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 0, active_sessions: 0, review_ready_sessions: 0, completed_sessions: 0, archived_sessions: 0, learner_turns: 0, client_turns: 0, last_practiced_at: null, }, growth: { trend: "insufficient", avg_rapport: null, avg_score: null, evaluated_sessions: 0, latest_score: null, first_score: null, score_delta: null, top_techniques: [], points: [], }, recent_feedback: [], persona_progress: [], achievements: [], training_exposure: { ...TRAINING_EXPOSURE_CONTRACT, status: "insufficient", label: "판정 근거 부족", completed_sessions: 0, dominant_persona_code: null, dominant_persona_name: null, dominant_sessions: 0, dominant_share: null, }, }; // 스포트라이트(진행 중 회기) 상세 — 리캡 fetch 오류 상태를 만들지 않기 위해 고정. const DETAIL_ACT = { case_id: "case-uclh", session_id: 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), }, ], }; // 대량 렌더 검증용 — 종료 회기 50건. const BULK_SESSIONS = Array.from({ length: 50 }, (_, i) => { const persona = i % 2 === 0 ? { code: "P1", name: "성하늘" } : { code: "P2", name: "김도윤" }; const stages = ["라포", "탐색", "개입", "정리"] as const; return { session_id: `uclhbulk${String(i + 1).padStart(2, "0")}`, session_no: 50 - i, persona_code: persona.code, persona_name: persona.name, status: "ended", stage: stages[i % 4], started_at: iso((i + 2) * HOUR), ended_at: iso((i + 1.5) * HOUR), review_ready: false, archived: false, archived_at: null, turn_count: 6, learner_turn_count: 3, client_turn_count: 3, }; }); const DASH_BULK = { ...DASH_NO_EVAL, overview: { ...DASH_NO_EVAL.overview, total_sessions: 50, active_sessions: 0, completed_sessions: 50, learner_turns: 150, client_turns: 150, }, training_exposure: { ...TRAINING_EXPOSURE_CONTRACT, status: "balanced", label: "균형", completed_sessions: 50, dominant_persona_code: "P1", dominant_persona_name: "성하늘", dominant_sessions: 25, dominant_share: 0.5, }, }; const DASH_ATTENTION = { ...DASH_NO_EVAL, overview: { ...DASH_NO_EVAL.overview, total_sessions: 4, active_sessions: 0, completed_sessions: 4, }, training_exposure: { ...TRAINING_EXPOSURE_CONTRACT, status: "attention", label: "훈련 집중 주의", completed_sessions: 4, dominant_persona_code: "P1", dominant_persona_name: "성하늘", dominant_sessions: 3, dominant_share: 0.75, }, }; interface FixtureOptions { sessions?: unknown[]; dashboard?: unknown; detail?: { session_id: string } | null; } async function installFixtures(page: Page, options: FixtureOptions = {}) { const { sessions = BASE_SESSIONS, dashboard = DASH_NO_EVAL, detail = DETAIL_ACT, } = options; await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill(jsonRoute({ sessions, source: "runtime" })), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(dashboard)), ); if (detail) { await page.route(new RegExp(`/api/sessions/${detail.session_id}$`), (route) => route.fulfill(jsonRoute(detail)), ); } } test.describe("uc learner home dashboard", () => { // usecase: 평가가 아직 없는 학습자가 성장 지표에서 현황 수치와 빈-평가 문구를 읽는다 test("평가 전 학습자가 성장 지표에서 전체 회기·리뷰·평가·라포 수치를 확인한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const cards = page.locator("#lh-panel-growth .lh-dashboard-status"); await expect(cards.getByRole("button")).toHaveCount(4); const progressCard = cards.getByRole("button", { name: /전체 회기/ }); await expect(progressCard).toContainText("2회"); await expect(progressCard).toContainText("상담 완료 1회 · 진행 중 1회"); const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ }); await expect(reviewCard).toContainText("0건"); await expect(reviewCard).toContainText("대기 중인 리뷰 없음"); const scoreCard = cards.getByRole("button", { name: /최근 평가/ }); await expect(scoreCard).toContainText("평가 부족"); await expect(scoreCard).toContainText("첫 회기 후 표시"); const rapportCard = cards.getByRole("button", { name: /라포 흐름/ }); await expect(rapportCard).toContainText("평가 부족"); // topPersona — 회기 수 동률이면 최근 회기가 더 최신인 P1(성하늘)이 우선. await expect(rapportCard).toContainText("성하늘 · 1회"); }); test("종료 회기 0회는 훈련 노출 판정 근거 부족으로 표시한다", async ({ page }) => { await installFixtures(page, { sessions: [], dashboard: EMPTY_DASHBOARD, detail: null, }); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({ hasText: "훈련 노출 점검", }); await expect(exposure).toContainText("판정 근거 부족"); await expect(exposure).toContainText("종료 회기 0/4회"); await expect(exposure).toContainText( "공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.", ); }); test("종료 회기 1회는 비중과 무관하게 훈련 노출 판정을 보류한다", async ({ page }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({ hasText: "훈련 노출 점검", }); await expect(exposure).toContainText("판정 근거 부족"); await expect(exposure).toContainText("종료 회기 1/4회"); }); test("종료 회기 50회의 동률 노출은 균형으로 표시한다", async ({ page }) => { await installFixtures(page, { sessions: BULK_SESSIONS, dashboard: DASH_BULK, detail: null, }); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({ hasText: "훈련 노출 점검", }); await expect(exposure).toContainText("균형"); await expect(exposure).toContainText("성하늘 25/50회 · 최다 노출 50%"); }); test("종료 회기 4회 중 3회 노출은 훈련 집중 주의로 표시한다", async ({ page }) => { await installFixtures(page, { dashboard: DASH_ATTENTION }); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({ hasText: "훈련 노출 점검", }); await expect(exposure).toContainText("훈련 집중 주의"); await expect(exposure).toContainText("성하늘 3/4회 · 최다 노출 75%"); await expect(exposure).toContainText( "공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.", ); }); // usecase: 학습자가 '리뷰 대기' 메트릭 카드를 눌러 기록 화면으로 이동한다 test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); await page .locator("#lh-panel-growth .lh-dashboard-status") .getByRole("button", { name: /리뷰 대기/ }) .click(); await expect(page).toHaveURL(/\/learn\/history$/); }); // usecase: 리뷰 대기가 없는 학습자가 추천 카드의 '연습 시작하기'로 연습 화면에 진입한다 test("리뷰 0건이면 추천 카드가 개인 연습을 권하고 시작 CTA가 연습 화면으로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); const recommend = page.locator(".lh-dashboard-recommend"); await expect( recommend.getByRole("heading", { name: "연습 목표를 좁힙니다." }), ).toBeVisible(); await expect(recommend).toContainText("개인 연습"); // 최근 평균 학습자 턴 (3+2)/2 → 3턴(<4) → 탐색 질문 늘리기 추천. await expect(recommend).toContainText("탐색 질문 늘리기"); await recommend.getByRole("button", { name: /연습 시작하기/ }).click(); await expect(page).toHaveURL(/\/learn\/practice$/); }); // usecase: 학습자가 성장 지표 탭에서 페르소나별 라포 게이지 %와 바 폭을 읽는다 test("성장 지표 탭의 라포 게이지가 rapport_percent 값과 바 폭을 함께 표시한다", async ({ page, }) => { await installFixtures(page, { dashboard: DASH_RAPPORT }); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const rapport = page.getByLabel("페르소나별 라포 누적"); const rows = rapport.locator(".lh-rapport-row"); await expect(rows).toHaveCount(2); // 회기 수 내림차순 — P1(3회, 81%)이 먼저. await expect(rows.nth(0)).toContainText("성하늘"); await expect(rows.nth(0)).toContainText("3회"); await expect(rows.nth(0).locator("b")).toHaveText("81%"); const firstBar = rows.nth(0).getByRole("progressbar"); await expect(firstBar).toHaveAttribute("aria-valuenow", "81"); await expect(firstBar.locator("i")).toHaveAttribute("style", /width:\s*81%/); await expect(rows.nth(1).locator("b")).toHaveText("47%"); await expect(rows.nth(1).getByRole("progressbar")).toHaveAttribute( "aria-valuenow", "47", ); }); // usecase: 학습자가 기록·리뷰 탭의 최근 기록에서 진행 중 회기를 눌러 이어한다 test("기록·리뷰 탭 최근 기록의 '진행 중' 버튼으로 회기를 이어간다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "기록 · 리뷰" }).click(); const recentPanel = page.locator("#lh-panel-records .lh-panel--records").first(); await expect( recentPanel.getByRole("heading", { name: "방금 끝낸 흐름입니다." }), ).toBeVisible(); await recentPanel.getByRole("button", { name: "진행 중" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`)); }); // usecase: 학습자가 기록·리뷰 탭의 최근 기록에서 종료 회기를 눌러 기록 리뷰로 이동한다 test("기록·리뷰 탭 최근 기록의 '종료' 버튼이 해당 회기 리뷰 화면으로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "기록 · 리뷰" }).click(); const recentPanel = page.locator("#lh-panel-records .lh-panel--records").first(); await recentPanel.getByRole("button", { name: "종료", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${END_ID}/review$`)); }); // usecase: 학습자가 연습 화면의 기존 회기 목록에서 진행 중 회기를 이어간다 test("연습 화면 기존 회기 목록의 '이어하기'가 진행 중 회기로 복귀한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); // 기본 선택 P1 — 기존 회기 목록에 진행 중(탐색 단계) 회기가 보인다. const activity = page.locator(".lh-activity"); await expect(activity).toContainText("P1 · 1회"); await activity.getByRole("button", { name: "이어하기" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`)); }); // usecase: 학습자가 내담자를 바꿔 선택한 뒤 새 사례로 세션 시작 화면에 진입한다 test("다른 내담자를 선택하고 '완전히 새로 시작'을 누르면 새 사례 세션 경로로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); await listbox.getByRole("option", { name: /P2/ }).click(); await expect(listbox.getByRole("option", { name: /P2/ })).toHaveAttribute( "aria-selected", "true", ); await page.getByRole("button", { name: "완전히 새로 시작" }).click(); await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/); }); // usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다 test("페르소나 카드가 난이도 라벨과 주호소 설명을 표기한다", async ({ page }) => { await installFixtures(page); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const p1 = listbox.getByRole("option", { name: /P1/ }); await expect(p1).toContainText("기초 · 인간중심"); await expect(p1).toContainText("학업 스트레스와 수면 문제를 호소"); const p2 = listbox.getByRole("option", { name: /P2/ }); await expect(p2).toContainText("중간 · 인지행동"); await expect(p2).toContainText("직장 내 대인관계 갈등을 호소"); }); // usecase: 학습자가 사용 불가 내담자를 구분하고 선택이 막힌 것을 확인한다 test("degraded 페르소나 카드는 '사용 불가' 표시와 함께 선택이 비활성화된다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const p3 = listbox.getByRole("option", { name: /P3/ }); await expect(p3).toContainText("사용 불가"); await expect(p3).toContainText( "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.", ); await expect(p3).toHaveAttribute("aria-disabled", "true"); await expect(p3).toBeDisabled(); }); // usecase: 학습자가 내담자 선택을 바꾸면 미리보기·기존 회기 카운트가 함께 갱신된다 test("내담자 선택을 바꾸면 미리보기 정보와 기존 회기 카운트가 갱신된다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); // 기본 선택 P1 미리보기 확인. await expect(page.getByRole("heading", { name: "성하늘" })).toBeVisible(); await page .getByRole("listbox", { name: "연습 페르소나" }) .getByRole("option", { name: /P2/ }) .click(); await expect(page.getByRole("heading", { name: "김도윤" })).toBeVisible(); const facts = page.locator(".lh-facts"); await expect(facts).toContainText("중간"); await expect(facts).toContainText("인지행동"); await expect(page.locator(".lh-summary")).toContainText( "직장 내 대인관계 갈등을 호소", ); await expect(page.locator(".lh-activity__head")).toContainText("P2 · 1회"); }); // usecase: 학습자가 종료 회기 50건이 쌓인 기록 화면을 끝까지 스크롤해 살펴본다 test("세션 50건 대량 기록이 전부 렌더되고 끝까지 스크롤해 볼 수 있다", async ({ page, }) => { await installFixtures(page, { sessions: BULK_SESSIONS, dashboard: DASH_BULK, detail: null }); await page.goto("/learn/history"); await expect(page.locator(".lh-history-main__head")).toContainText("50개"); const cards = page.locator(".lh-session-list .lh-session-card"); await expect(cards).toHaveCount(50); // 가장 오래된 회기까지 스크롤 — 마지막 카드가 실제로 보인다. await cards.last().scrollIntoViewIfNeeded(); await expect(cards.last()).toBeVisible(); await expect(cards.last()).toContainText("김도윤"); await expectNoHorizontalOverflow(page); }); // usecase: 첫 방문 학습자가 빈 대시보드에서 첫 회기 안내를 읽고 연습 화면으로 이동한다 test("신규 학습자 빈 대시보드가 첫 회기 안내와 함께 학습 대상 선택으로 이끈다", async ({ page, }) => { await installFixtures(page, { sessions: [], dashboard: EMPTY_DASHBOARD, detail: null }); await page.goto("/learn"); // 코치가 첫 회기 시작을 안내한다. const coachCard = page.getByLabel("AI 코치"); await expect( coachCard.getByRole("heading", { name: "첫 회기를 시작할 차례입니다." }), ).toBeVisible(); // 스포트라이트는 '첫 회기 준비' + 기초 우선 페르소나(P1 성하늘, 결정 D3). const focus = page.locator(".lh-session-focus"); await expect(focus).toContainText("첫 회기 준비"); await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible(); await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기"); // 전체 회기 메트릭도 0회. await page.getByRole("tab", { name: "성장 지표" }).click(); await expect( page.locator("#lh-panel-growth .lh-dashboard-status").getByRole("button", { name: /전체 회기/ }), ).toContainText("0회"); // 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다. const primaryAction = page.locator("[data-learner-primary-action]"); await expect(primaryAction).toHaveAccessibleName("학습 대상 선택"); await primaryAction.click(); await expect(page).toHaveURL(/\/learn\/practice$/); }); test("성장 지표 라포 헤더는 중첩 inset 표면을 사용하지 않는다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); await page.getByRole("tab", { name: "성장 지표" }).click(); const rapportHeading = page .locator("#lh-panel-growth") .getByRole("heading", { name: "회기를 건너 누적됩니다." }); const headingContent = rapportHeading.locator(".."); await expect(rapportHeading).toBeVisible(); await expect(headingContent).not.toHaveClass(/vg-surface/); await expect .poll(() => headingContent.evaluate((element) => { const style = getComputedStyle(element); return { backgroundColor: style.backgroundColor, borderTopWidth: style.borderTopWidth, borderRadius: style.borderRadius, padding: style.padding, }; }), ) .toEqual({ backgroundColor: "rgba(0, 0, 0, 0)", borderTopWidth: "0px", borderRadius: "0px", padding: "0px", }); }); // usecase: 첫 방문 학습자가 기록·성장 탭에서 빈 상태 안내 문구를 확인한다 test("신규 학습자의 기록·성장 탭이 빈 상태 안내 문구를 보여준다", async ({ page, }) => { await installFixtures(page, { sessions: [], dashboard: EMPTY_DASHBOARD, detail: null }); await page.goto("/learn"); await page.getByRole("tab", { name: "기록 · 리뷰" }).click(); const records = page.locator("#lh-panel-records"); await expect(records).toContainText("최근 기록이 없습니다."); await expect(records).toContainText("대기 중인 리뷰가 없습니다."); await page.getByRole("tab", { name: "성장 지표" }).click(); const growthPanel = page.locator("#lh-panel-growth"); await expect(growthPanel).toContainText( "첫 회기를 끝내면 라포 누적이 표시됩니다.", ); await expect(growthPanel).toContainText("첫 회기 후 표시"); }); // usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 사례 회기 시작 화면으로 이동한다 test("키보드 Tab 이동과 Enter만으로 새 사례 회기를 시작할 수 있다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const p1 = listbox.getByRole("option", { name: /P1/ }); await expect(p1).toHaveAttribute("aria-selected", "true"); // 선택된 페르소나 카드에서 시작해 Tab으로 '완전히 새로 시작' 버튼까지 이동한다. await p1.focus(); const startButton = page.getByRole("button", { name: "완전히 새로 시작" }); let reached = false; for (let i = 0; i < 25; i += 1) { await page.keyboard.press("Tab"); if (await startButton.evaluate((el) => el === document.activeElement)) { reached = true; break; } } expect(reached, "Tab 이동으로 '완전히 새로 시작' 버튼에 도달해야 한다").toBe(true); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/); }); // usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다 test("대시보드 영역 탭이 화살표·End 키 이동을 지원한다", async ({ page }) => { await installFixtures(page); await page.goto("/learn"); const todayTab = page.getByRole("tab", { name: "오늘의 회기" }); await todayTab.click(); await expect(todayTab).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("ArrowRight"); const recordsTab = page.getByRole("tab", { name: "기록 · 리뷰" }); await expect(recordsTab).toHaveAttribute("aria-selected", "true"); await expect(recordsTab).toBeFocused(); await expect(page.locator("#lh-panel-records")).toBeVisible(); await page.keyboard.press("End"); const growthTab = page.getByRole("tab", { name: "성장 지표" }); await expect(growthTab).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#lh-panel-growth")).toBeVisible(); // 왼쪽 끝에서 ArrowLeft는 마지막 탭으로 순환한다. await page.keyboard.press("Home"); await expect(todayTab).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("ArrowLeft"); await expect(growthTab).toHaveAttribute("aria-selected", "true"); }); });