// 2026-07-27 E2E 전수 순회 — 학습자 홈(learner-home) 영역 신규 스펙. // docs/ops/e2e-full-sweep-2026-07-27.md §2에서 "검증: 신규 spec 필요"로 표기된 // 체크리스트 항목을 검증한다. 실 API 로그인(dev-login) 위에 route fixture로 // 페르소나·회기·대시보드 응답을 고정해 결정론적으로 UI 상태를 검증하며, // 실제 AI 엔진 턴 생성은 일절 수행하지 않는다. import { expect, test, type Page } from "@playwright/test"; import { signInAsLearner, useRealApi } from "./support"; const ACT_ID = "lhact001"; const REV_ID = "lhrev001"; const END_ID = "lhend001"; const ARC_ID = "lharc001"; const NOW = Date.now(); const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); const HOUR = 60 * 60 * 1000; 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, }, ]; const S_ACT = { session_id: ACT_ID, session_no: 4, 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_REV = { session_id: REV_ID, session_no: 3, persona_code: "P1", 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 S_END = { session_id: END_ID, session_no: 2, 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 S_ARC = { session_id: ARC_ID, session_no: 1, persona_code: "P1", persona_name: "성하늘", status: "ended", stage: "개입", started_at: iso(8 * HOUR), ended_at: iso(7 * HOUR), review_ready: false, archived: true, archived_at: iso(6 * HOUR), turn_count: 8, learner_turn_count: 4, client_turn_count: 4, }; const SESSIONS_BODY = { sessions: [S_ACT, S_REV, S_END, S_ARC], source: "runtime", }; const DASHBOARD_BODY = { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 4, active_sessions: 1, review_ready_sessions: 1, completed_sessions: 3, archived_sessions: 1, learner_turns: 14, client_turns: 14, last_practiced_at: iso(1 * HOUR), }, growth: { trend: "down", avg_rapport: 0.2, avg_score: 0.6, evaluated_sessions: 3, latest_score: 0.62, first_score: 0.7, score_delta: -0.08, top_techniques: ["감정 반영"], points: [ { session_id: REV_ID, session_no: 3, persona_code: "P1", stage: "정리", started_at: iso(3 * HOUR), ended_at: iso(2.5 * HOUR), score: 0.62, rapport: 0.2, technique_count: 3, watch_count: 2, }, ], }, recent_feedback: [ { session_id: REV_ID, session_no: 3, persona_code: "P1", persona_name: "성하늘", stage: "정리", score: 0.62, rapport: 0.2, note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.", created_at: iso(2.5 * HOUR), turn_seq: 6, techniques: ["감정 반영"], }, ], persona_progress: [ { persona_code: "P1", persona_name: "성하늘", sessions: 3, active_sessions: 1, completed_sessions: 2, review_ready_sessions: 1, rapport_percent: 64, trend: "down", latest_stage: "탐색", latest_score: 0.62, latest_at: iso(1 * HOUR), }, { persona_code: "P2", persona_name: "김도윤", sessions: 1, active_sessions: 0, completed_sessions: 1, review_ready_sessions: 0, rapport_percent: 32, trend: "insufficient", latest_stage: "라포", latest_score: null, latest_at: iso(4.5 * HOUR), }, ], achievements: [ { id: "first-session", label: "첫 회기 완료", detail: "첫 회기를 끝냈습니다.", state: "done", }, { id: "five-sessions", label: "다섯 회기 도전", detail: "다섯 번째 회기를 끝내면 열립니다.", state: "locked", }, ], }; const DETAIL_BODY = { case_id: "case-lh", 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), }, ], }; // 결정 D3 — 신규 학습자 기초 우선 추천 검증용 fixture. // P1을 고난도, P4를 기초로 두어 API 순서(P1 먼저)와 난도 오름차순(P4 먼저)이 갈리게 한다. const STARTER_END_ID = "lhstend1"; const STARTER_PERSONAS_BODY = [ { code: "P1", display_name: "성하늘", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "20대" }, presenting_summary: "학업 스트레스와 수면 문제를 호소", voice_preset: null, source: "database", degraded: false, }, { code: "P4", display_name: "박새봄", difficulty: "easy", theory_target: ["cbt"], demographics: { age_band: "30대" }, presenting_summary: "출근길 불안과 긴장을 호소", voice_preset: null, source: "database", degraded: false, }, ]; const S_STARTER_END = { session_id: STARTER_END_ID, session_no: 1, persona_code: "P1", 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 EMPTY_DASHBOARD_BODY = { 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: [], }; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } /** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */ async function installLearnerHomeFixtures( page: Page, options: { detail?: boolean; dashboard?: typeof DASHBOARD_BODY; } = {}, ) { const { detail = true, dashboard = DASHBOARD_BODY } = options; await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill(jsonRoute(SESSIONS_BODY)), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(dashboard)), ); if (detail) { await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) => route.fulfill(jsonRoute(DETAIL_BODY)), ); } } /** 결정 D3 검증용 — 난도 역순 페르소나 + 회기 0건(또는 P1 종료 1건)을 고정한다. */ async function installStarterFixtures( page: Page, options: { withRecords?: boolean } = {}, ) { const { withRecords = false } = options; await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(STARTER_PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill( jsonRoute({ sessions: withRecords ? [S_STARTER_END] : [], source: "runtime", }), ), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)), ); if (withRecords) { await page.route(new RegExp(`/api/sessions/${STARTER_END_ID}$`), (route) => route.fulfill( jsonRoute({ ...DETAIL_BODY, session_id: STARTER_END_ID, status: "ended", stage: "라포", ended_at: iso(4.5 * HOUR), }), ), ); } } async function installLongPersonaFixtures(page: Page) { const personas = Array.from({ length: 16 }, (_, index) => { const number = index + 1; return { code: `P${number}`, display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`, difficulty: number === 4 ? "easy" : "hard", theory_target: ["cbt"], demographics: { age_band: "30대" }, presenting_summary: "반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.", voice_preset: null, source: "database", degraded: false, }; }); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(personas))); await page.route(/\/api\/sessions$/, (route) => route.fulfill(jsonRoute({ sessions: [], source: "runtime" })), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)), ); } test.describe("full-sweep learner home", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); // checklist: learner-home-guard-pending-approval test("redirects a pending learner from /learn to /pending", async ({ page }) => { await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "pending-learner", email: "pending.learner@hs.ac.kr", display_name: "Pending Learner", role: "learner", admin_access: false, super_admin: false, account_status: "pending", approval_required: true, cohort_ids: [], consent_at: 1, onboarding_completed_at: 1, nickname: "Pending Learner", self_introduction: "", avatar_url: "", }), ), ); await page.goto("/learn"); await expect(page).toHaveURL(/\/pending$/); await expect(page.getByText("승인 대기")).toBeVisible(); await expect(page.locator(".pa-status")).toHaveAttribute("role", "status"); await expect( page.getByRole("button", { name: "승인 상태 새로고침" }), ).toBeVisible(); }); // checklist: learner-home-head-action test("shows the action-focused dashboard header without a global count badge", async ({ page, }) => { await signInAsLearner(page); await page.goto("/learn"); await expect( page.getByRole("heading", { name: "오늘의 상담 연습" }), ).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".lh-head__status")).toHaveCount(0); await expect(page.locator("main")).toHaveCount(1); }); // checklist: learner-home-dash-metric-cards test("renders four dashboard metric cards and routes them to history and practice", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(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); await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText( "4회", ); await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText( "상담 완료 3회 · 진행 중 1회", ); await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText( "1건", ); await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText( "62점", ); await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText( "3회 평가 · 점검 필요", ); await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText( "+0.20", ); await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText( "성하늘 · 3회", ); await cards.getByRole("button", { name: /전체 회기/ }).click(); await expect(page).toHaveURL(/\/learn\/history$/); 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\/practice$/); }); test("labels all sessions separately from completed counseling and empty ended sessions", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page, { dashboard: { ...DASHBOARD_BODY, overview: { ...DASHBOARD_BODY.overview, total_sessions: 5, completed_sessions: 2, active_sessions: 0, }, }, }); await page.goto("/learn"); await expect( page.locator("#lh-panel-today .lh-dashboard-status"), ).toHaveCount(0); await page.getByRole("tab", { name: "성장 지표" }).click(); const allSessions = page .locator("#lh-panel-growth .lh-dashboard-status") .getByRole("button", { name: "전체 회기" }); await expect(allSessions).toContainText("5회"); await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회"); await expect(allSessions).toContainText("대화 없이 종료한 회기 포함"); await expect(allSessions).toHaveAttribute( "title", "전체 회기에는 대화 없이 종료한 회기도 포함합니다.", ); }); // checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta test("shows the spotlight recap card and coach CTA for an active session", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn"); // 리캡 카드 — 우선순위 스포트라이트(진행 중 회기)의 아바타·단계·진행률·마지막 발화. 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( "진행 중", ); const recap = page.getByLabel("마지막 세션 리캡"); await expect(recap).toContainText("6턴"); await expect(recap).toContainText("탐색"); await expect(recap.locator(".lh-recap__progress")).toContainText("50%"); await expect(recap.locator(".lh-recap__quote")).toContainText( "요즘은 잠이 잘 안 와요.", ); await expect(recap.locator(".lh-recap__learner")).toContainText( "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?", ); // AI 코치 카드 — 진행 중 회기 우선 규칙 문구 + 힌트 칩 + CTA가 세션으로 이동. const coachCard = page.getByLabel("AI 코치"); await expect( coachCard.getByRole("heading", { name: "진행 중인 회기를 먼저 마무리합니다.", }), ).toBeVisible(); await expect(coachCard.getByLabel("코칭 힌트")).toContainText( "마지막 반응 먼저 반영", ); const primaryAction = page.locator("[data-learner-primary-action]"); await expect(primaryAction).toHaveAccessibleName("이어하기"); await primaryAction.click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`)); }); test("naturalizes persisted privacy placeholders in the spotlight recap", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page, { detail: false }); await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) => route.fulfill( jsonRoute({ ...DETAIL_BODY, turns: [ { ...DETAIL_BODY.turns[0], text: "[NAME]에게 [PHONE]으로 연락해 볼까요?", }, { ...DETAIL_BODY.turns[1], text: "뭘… 할 수 있게 [NAME]는 건지 잘 [NAME]는데요. [ORG]에서 오라고 했어요.", }, ], }), ), ); await page.goto("/learn"); const recap = page.getByLabel("마지막 세션 리캡"); await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요"); await expect(recap).toContainText("소속 기관에서 오라고 했어요"); await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요"); for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) { await expect(recap).not.toContainText(token); } }); // checklist: learner-home-dash-recommend-card, learner-home-dash-recent-feedback, // learner-home-dash-review-queue test("prioritizes review in the recommend card, recent feedback, and review queue", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn"); // 다음 연습 추천 — 리뷰 대기가 있으면 리뷰 우선 문구/이동. const recommend = page.locator(".lh-dashboard-recommend"); await expect( recommend.getByRole("heading", { name: "리뷰 확인을 우선합니다." }), ).toBeVisible(); await expect(recommend).toContainText("1건의 회기 피드백 확인"); await recommend.getByRole("button", { name: /리뷰 확인하기/ }).click(); await expect(page).toHaveURL(/\/learn\/history$/); // 최근 피드백 — 첫 항목 버튼이 해당 회기 리뷰로 이동. await page.goto("/learn"); const feedback = page.locator(".lh-dashboard-feedback"); await expect(feedback.locator(".lh-panel__badge")).toHaveText("1건"); const feedbackRow = feedback.locator(".lh-feedback-mini__row"); const feedbackMeta = feedbackRow.locator("b > span"); await expect(feedbackMeta.nth(0)).toHaveText("P1 · 정리"); await expect(feedbackMeta.nth(1)).toHaveText("62점"); await expect(feedbackRow).toContainText( "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.", ); await feedbackRow.click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`)); // 리뷰 대기 패널 — review_ready 상위 항목의 'AI 피드백' 버튼이 리뷰로 진입. // 대시보드 셸은 전 폭에서 가로 탭으로 영역을 전환하므로 '기록 · 리뷰' 탭을 먼저 연다. await page.goto("/learn"); await page.getByRole("tab", { name: "기록 · 리뷰" }).click(); await expect( page.getByRole("heading", { name: "피드백 받을 기록입니다." }), ).toBeVisible(); const reviewButtons = page.getByRole("button", { name: "AI 피드백" }); await expect(reviewButtons).toHaveCount(1); await reviewButtons.first().click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`)); }); // checklist: learner-home-dash-weakness-insights, learner-home-dash-rapport-progress, // learner-home-dash-milestones test("derives weakness insights, rapport bars, and milestone badges from dashboard data", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn"); // 성장 지표 패널들은 '성장 지표' 탭 아래에 배치된다(대시보드 셸 가로 탭). await page.getByRole("tab", { name: "성장 지표" }).click(); // 보완할 부분 — growth 데이터에서 결정론으로 파생된 인사이트 최대 3건. const weakness = page.locator(".lh-weakness"); await expect(weakness).toBeVisible(); const weaknessButtons = weakness.locator(".lh-weakness__list button"); await expect(weaknessButtons).toHaveCount(3); await expect(weaknessButtons.nth(0)).toContainText("평가가 내려가는 흐름"); await expect(weaknessButtons.nth(1)).toContainText("라포 신호가 아직 약함"); await expect(weaknessButtons.nth(2)).toContainText("살펴볼 개입 2건"); // 라포 누적 — 회기가 있는 페르소나 상위 3명의 rapport_percent progressbar. const rapport = page.getByLabel("페르소나별 라포 누적"); const bars = rapport.getByRole("progressbar"); await expect(bars).toHaveCount(2); await expect(bars.nth(0)).toHaveAttribute("aria-valuenow", "64"); await expect(bars.nth(1)).toHaveAttribute("aria-valuenow", "32"); await expect(rapport).toContainText("성하늘"); await expect(rapport).toContainText("64%"); // 연습 이정표 — achievements 상태별 배지 + title 툴팁. const milestones = page.locator(".lh-milestones"); await expect(milestones).toContainText("첫 회기 완료"); await expect(milestones.locator(".lh-milestone.is-done")).toHaveAttribute( "title", "첫 회기를 끝냈습니다.", ); await expect(milestones.locator(".lh-milestone.is-locked")).toContainText( "다섯 회기 도전", ); // 인사이트 버튼 클릭 시 해당 화면으로 이동. await weaknessButtons.nth(1).click(); await expect(page).toHaveURL(/\/learn\/practice$/); }); // checklist: learner-home-dash-recap-fetch-states test("shows recap detail loading, error fallback, and ignores stale detail responses", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page, { detail: false }); // 1) 로딩 → 성공: 상세 응답을 지연시켜 로딩 문구를 확인한 뒤 발화를 확정한다. let releaseDetail!: () => void; const detailGate = new Promise((resolve) => { releaseDetail = resolve; }); const detailPattern = new RegExp(`/api/sessions/${ACT_ID}$`); await page.route(detailPattern, async (route) => { await detailGate; await route.fulfill(jsonRoute(DETAIL_BODY)); }); await page.goto("/learn"); const quote = page.locator(".lh-recap__quote"); await expect(quote).toContainText("상세 발화를 확인하고 있습니다."); releaseDetail(); await expect(quote).toContainText("요즘은 잠이 잘 안 와요."); // 2) 오류: 상세 로드 실패 시 발화 없음 폴백 문구로 전환된다. await page.unroute(detailPattern); await page.route(detailPattern, (route) => route.fulfill(jsonRoute({ detail: "boom" }, 500)), ); await page.reload(); await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다."); // 3) stale 응답: session_id가 다른 상세는 무시되어 발화가 표시되지 않는다. await page.unroute(detailPattern); await page.route(detailPattern, (route) => route.fulfill(jsonRoute({ ...DETAIL_BODY, session_id: "other-session" })), ); await page.reload(); await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다."); await expect(quote).not.toContainText("요즘은 잠이 잘 안 와요."); }); // checklist: learner-home-practice-persona-skeleton test("shows persona skeletons while loading and a role=alert notice on failure", async ({ page, }) => { await signInAsLearner(page); let releasePersonas!: () => void; const personasGate = new Promise((resolve) => { releasePersonas = resolve; }); await page.route(/\/api\/personas$/, async (route) => { await personasGate; await route.fulfill(jsonRoute({ detail: "boom" }, 500)); }); await page.goto("/learn/practice"); // 로딩 중 스켈레톤 3개. await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3); // 실패 시 role=alert 안내. releasePersonas(); const alert = page.locator(".lh-error"); await expect(alert).toHaveAttribute("role", "alert"); await expect(alert).toContainText("내담자 목록을 불러오지 못했습니다."); await expect(alert).toContainText("잠시 뒤 다시 시도해 주세요."); }); // checklist: learner-home-session-list-states test("distinguishes session list loading, error, and empty states", async ({ page, }) => { await signInAsLearner(page); let releaseSessions!: () => void; const sessionsGate = new Promise((resolve) => { releaseSessions = resolve; }); const sessionsPattern = /\/api\/sessions$/; await page.route(sessionsPattern, async (route) => { await sessionsGate; await route.fulfill(jsonRoute({ detail: "boom" }, 500)); }); await page.goto("/learn/practice"); // 로딩 문구. await expect( page.getByText("저장된 연습 기록을 확인하고 있습니다."), ).toBeVisible(); // 오류: 인라인 경고(role=status). releaseSessions(); const inlineAlert = page.locator(".lh-activity .lh-inline-alert"); await expect(inlineAlert).toContainText("연습 기록을 불러오지 못했습니다."); await expect(inlineAlert).toHaveAttribute("role", "status"); // 빈 상태: 컨텍스트별 빈 메시지. await page.unroute(sessionsPattern); await page.route(sessionsPattern, (route) => route.fulfill(jsonRoute({ sessions: [], source: "runtime" })), ); await page.reload(); const empty = page.locator(".lh-activity__empty"); await expect(empty).toContainText("저장된 기존 회기가 없습니다."); await expect(empty).toContainText( "첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.", ); await expect(empty).toHaveAttribute("role", "status"); }); // checklist: learner-home-history-overview-cards, learner-home-history-filter-chips, // learner-home-history-search test("filters history via overview cards, filter chips, and search", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn/history"); // 요약 필터 카드 4개 + 버킷 카운트 + aria-pressed. const overview = page.locator(".lh-history-overview"); await expect(overview.getByRole("button")).toHaveCount(4); await expect(overview.getByRole("button", { name: /전체/ })).toContainText( "4", ); await expect( overview.getByRole("button", { name: /전체/ }), ).toHaveAttribute("aria-pressed", "true"); await expect( overview.getByRole("button", { name: /진행 중/ }), ).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".lh-history-main__head")).toContainText("4개"); // 카드 클릭으로 필터 전환. await overview.getByRole("button", { name: /진행 중/ }).click(); await expect( overview.getByRole("button", { name: /진행 중/ }), ).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".lh-history-main__head")).toContainText("1개"); await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible(); // 칩과 카드가 같은 필터 상태를 공유한다. const chips = page.locator(".lh-history-filter"); await expect(chips.getByRole("button", { name: "진행 중" })).toHaveAttribute( "aria-pressed", "true", ); await chips.getByRole("button", { name: "보관됨" }).click(); await expect( overview.getByRole("button", { name: /보관됨/ }), ).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".lh-history-main__head")).toContainText("1개"); await expect(page.getByRole("button", { name: "복원" })).toBeVisible(); // 검색 — 단계 문자열 부분 일치 즉시 필터링. await chips.getByRole("button", { name: "전체" }).click(); await expect(page.locator(".lh-history-main__head")).toContainText("4개"); const search = page.getByLabel("기록 검색"); await search.fill("탐색"); await expect(page.locator(".lh-history-main__head")).toContainText("1개"); await expect(page.locator(".lh-session-list")).toContainText("탐색 단계"); await search.fill("존재하지않는검색어"); await expect(page.locator(".lh-history-main__head")).toContainText("0개"); await expect( page.getByText("조건에 맞는 회기가 없습니다."), ).toBeVisible(); }); // checklist: learner-home-history-archive-notice test("surfaces archive failure and success notices with role=status", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); let archiveCalls = 0; await page.route(new RegExp(`/api/sessions/${END_ID}/archive$`), (route) => { archiveCalls += 1; if (archiveCalls === 1) { return route.fulfill(jsonRoute({ detail: "archive failed" }, 500)); } return route.fulfill( jsonRoute({ session_id: END_ID, archived: true, archived_at: iso(0), session: { ...S_END, archived: true, archived_at: iso(0) }, source: "runtime", }), ); }); await page.goto("/learn/history"); const endCard = page.locator(".lh-session-card", { hasText: "김도윤" }); await expect(endCard).toBeVisible(); // 실패: role=status 배너에 실패 문구. await endCard.getByRole("button", { name: "보관", exact: true }).click(); const notice = page.locator(".lh-inline-alert--archive"); await expect(notice).toContainText("보관 상태를 바꾸지 못했습니다."); await expect(notice).toHaveAttribute("role", "status"); // 성공: 같은 배너 위치에 성공 문구. await endCard.getByRole("button", { name: "보관", exact: true }).click(); await expect(notice).toContainText("회기를 보관했습니다."); await expect(notice).toHaveAttribute("role", "status"); }); // checklist: learner-home-history-persona-progress test("lists persona progress rows and jumps to practice with the persona selected", async ({ page, }) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn/history"); const progress = page.getByLabel("페르소나별 진행 정도"); await expect(progress).toContainText("2명"); const rows = progress.locator(".lh-persona-progress__row"); await expect(rows).toHaveCount(2); // 회기 수 내림차순 정렬 — P1(3회)이 먼저 온다. await expect(rows.nth(0)).toContainText("P1"); const firstMeta = rows.nth(0).locator(".lh-persona-progress__meta"); await expect(firstMeta.locator(":scope > span").nth(0)).toHaveText( "3회 · 평균 4턴", ); await expect(firstMeta.locator(":scope > span").nth(1)).toHaveText( "진행 1 · 리뷰 1", ); await expect( rows.nth(0).locator(".lh-persona-progress__latest"), ).toHaveText("진행 중 · 탐색"); await expect(rows.nth(1)).toContainText("P2"); // 행 클릭 시 해당 페르소나가 선택된 채 연습 화면으로 진입. await rows.nth(1).click(); await expect(page).toHaveURL(/\/learn\/practice$/); await expect( page.getByRole("heading", { name: "김도윤" }), ).toBeVisible(); await expect( page .getByRole("listbox", { name: "연습 페르소나" }) .getByRole("option", { name: /P2/ }), ).toHaveAttribute("aria-selected", "true"); }); // checklist: learner-home-newbie-basic-first (결정 D3) test("defaults a learner with no records to the easiest persona in dashboard and practice", async ({ page, }) => { await signInAsLearner(page); await installStarterFixtures(page); // (a) 대시보드 첫 회기 준비 카드가 API 순서(P1·고난도)가 아니라 기초(P4)를 보여준다. await page.goto("/learn"); const focus = page.locator(".lh-session-focus"); await expect(focus).toContainText("첫 회기 준비"); await expect(focus.getByRole("heading", { name: "박새봄" })).toBeVisible(); await expect(focus).toContainText("기초"); await expect(page.getByLabel("마지막 세션 리캡")).toContainText("P4"); // (b) /learn/practice 기본 선택도 난도 오름차순 첫 사용 가능 페르소나(P4)다. await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); await expect( listbox.getByRole("option", { name: /P4/ }), ).toHaveAttribute("aria-selected", "true"); await expect( listbox.getByRole("option", { name: /P1/ }), ).toHaveAttribute("aria-selected", "false"); await expect( page.getByRole("heading", { name: "박새봄" }), ).toBeVisible(); }); // checklist: learner-home-newbie-basic-first (결정 D3 — 기록 보유 시 무회귀) test("keeps the recent-session rule when the learner already has records", async ({ page, }) => { await signInAsLearner(page); await installStarterFixtures(page, { withRecords: true }); // (c) 기록이 있으면 스포트라이트는 최근 회기 페르소나(P1)를 유지한다. await page.goto("/learn"); const focus = page.locator(".lh-session-focus"); await expect(focus).toContainText("오늘 이어갈 회기"); await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible(); // 기본 선택도 기존 규칙(첫 사용 가능 페르소나 = P1)을 유지한다. await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); await expect( listbox.getByRole("option", { name: /P1/ }), ).toHaveAttribute("aria-selected", "true"); await expect( listbox.getByRole("option", { name: /P4/ }), ).toHaveAttribute("aria-selected", "false"); }); test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => { await signInAsLearner(page); await installStarterFixtures(page); await page.setViewportSize({ width: 320, height: 844 }); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const selected = listbox.getByRole("option", { name: /P4/ }); await expect(selected).toHaveAttribute("aria-selected", "true"); await expect.poll(() => page.evaluate(() => { const list = document.querySelector(".lh-personas"); const option = document.querySelector("[data-persona-code='P4']"); if (!list || !option) return false; const listRect = list.getBoundingClientRect(); const optionRect = option.getBoundingClientRect(); const subpixelTolerance = 1; return ( optionRect.left >= listRect.left - subpixelTolerance && optionRect.right <= listRect.right + subpixelTolerance && optionRect.top >= listRect.top - subpixelTolerance && optionRect.bottom <= listRect.bottom + subpixelTolerance ); })).toBe(true); await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); await page.screenshot({ path: testInfo.outputPath("p4-initial-mobile-320.png"), fullPage: false, animations: "disabled", }); }); test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => { await signInAsLearner(page); await installStarterFixtures(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const selected = listbox.getByRole("option", { name: /P4/ }); await expect(selected).toHaveAttribute("aria-selected", "true"); await page.setViewportSize({ width: 320, height: 844 }); await expect.poll(() => page.evaluate(() => { const list = document.querySelector(".lh-personas"); const option = document.querySelector("[data-persona-code='P4']"); if (!list || !option) return false; const listRect = list.getBoundingClientRect(); const optionRect = option.getBoundingClientRect(); const subpixelTolerance = 1; return ( optionRect.left >= listRect.left - subpixelTolerance && optionRect.right <= listRect.right + subpixelTolerance && optionRect.top >= listRect.top - subpixelTolerance && optionRect.bottom <= listRect.bottom + subpixelTolerance ); })).toBe(true); await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); }); test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => { await signInAsLearner(page); await installLongPersonaFixtures(page); for (const width of [768, 1024, 1440]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/learn/practice"); const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const selected = listbox.getByRole("option", { name: /P4/ }); const longPersona = listbox.getByRole("option", { name: /P12/ }); const lastPersona = listbox.getByRole("option", { name: /P16/ }); const startCta = page.getByRole("button", { name: "완전히 새로 시작" }); await expect(selected).toHaveAttribute("aria-selected", "true"); await expect(longPersona).toBeVisible(); await expect.poll(() => page.evaluate(() => { const list = document.querySelector(".lh-personas"); const option = document.querySelector("[data-persona-code='P4']"); if (!list || !option) return false; const listRect = list.getBoundingClientRect(); const optionRect = option.getBoundingClientRect(); const options = [...list.querySelectorAll("[role='option']")]; const rowsDoNotOverlap = options.every((current, index) => { const currentRect = current.getBoundingClientRect(); return options.slice(index + 1).every((other) => { const otherRect = other.getBoundingClientRect(); return ( currentRect.right <= otherRect.left || otherRect.right <= currentRect.left || currentRect.bottom <= otherRect.top || otherRect.bottom <= currentRect.top ); }); }); const contentFits = options.every((current) => { const content = current.querySelector(".lh-persona__body"); return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom; }); const hit = document.elementFromPoint( optionRect.left + optionRect.width / 2, optionRect.top + optionRect.height / 2, ); return ( list.scrollHeight > list.clientHeight && getComputedStyle(list).overflowY === "auto" && optionRect.top >= listRect.top && optionRect.bottom <= listRect.bottom && rowsDoNotOverlap && contentFits && !!hit && option.contains(hit) ); })).toBe(true); await expect(startCta).toBeInViewport(); await page.screenshot({ path: testInfo.outputPath(`long-personas-w${width}-top.png`), fullPage: false, animations: "disabled", }); await longPersona.scrollIntoViewIfNeeded(); await expect(longPersona).toBeInViewport(); await listbox.evaluate((element) => { element.scrollTop = element.scrollHeight; }); await expect(lastPersona).toBeInViewport(); await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); await page.screenshot({ path: testInfo.outputPath(`long-personas-w${width}-bottom.png`), fullPage: false, animations: "disabled", }); } }); test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => { await signInAsLearner(page); await installStarterFixtures(page); for (const width of [320, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto("/learn/history"); const overview = page.locator(".lh-history-overview"); const practiceCta = page.getByRole("button", { name: "상담 연습으로" }); const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ }); await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible(); await expect(practiceCta).toBeVisible(); await expect(toggle).toBeVisible(); const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length); expect(columns).toBe(2); for (const target of [practiceCta, toggle]) { const height = await target.evaluate((element) => element.getBoundingClientRect().height); expect(height).toBeGreaterThanOrEqual(44); } await toggle.scrollIntoViewIfNeeded(); await page.locator(".lh-persona-progress").screenshot({ path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`), animations: "disabled", }); await toggle.click(); await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2); await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click(); await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0); } }); test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => { await signInAsLearner(page); await installLearnerHomeFixtures(page); for (const width of [320, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto("/learn/history"); const filters = page.locator(".lh-history-filter button"); const historyActions = page.locator(".lh-session-actions button"); await expect(filters).toHaveCount(4); await expect(historyActions).not.toHaveCount(0); for (const target of [filters, historyActions]) { const boxes = await target.evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); return { height: rect.height, width: rect.width }; }), ); expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true); } expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); if (width === 320) { await page.screenshot({ path: testInfo.outputPath("history-top-mobile-320.png"), fullPage: false, animations: "disabled", }); await historyActions.first().locator("..").locator("..").screenshot({ path: testInfo.outputPath("history-action-row-mobile-320.png"), animations: "disabled", }); } await page.goto("/learn/practice"); const practiceActions = page.locator(".lh-session-actions button"); await expect(practiceActions).not.toHaveCount(0); const practiceBoxes = await practiceActions.evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); return { height: rect.height, width: rect.width }; }), ); expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); } }); });