// uc-learner-practice-history — 학습자 연습(practice)·기록(history) 뷰 유스케이스 스펙. // // 목적: 학습자가 연습 대상을 고르고 새 회기를 시작하는 여정과, 회기 기록을 // 검색·필터·정렬 규칙에 따라 찾아 리뷰/재연습으로 진입하는 여정을 검증한다. // 근거: apps/web/src/pages/LearnerHome.tsx(practice/history 뷰), // apps/web/src/pages/learner-home/model.ts(정렬·라벨·빈 문구·날짜 표기 규칙). // full-sweep-learner-home.spec.ts가 이미 다루는 필터 카드·칩 단독 전환, // 아카이브 성공/실패 알림, 결정 D3 기본 케이스는 중복 커버하지 않고 // (검색+필터 조합, degraded 스킵, 난도 동률 code 순 등) 다른 각도로 비튼다. // 모든 응답은 route fixture로 고정하며 실제 AI 엔진 턴 생성은 일절 없다. import { test, expect, 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(); 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-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: "2026-08-01T00:00:00+00:00", onboarding_completed_at: "2026-08-01T00:00:00+00:00", nickname: "학습자", self_introduction: "", avatar_url: "", }), ), ); } // ── 페르소나 fixture ───────────────────────────────────────────────────────── function makePersona( code: string, displayName: string, difficulty: string, overrides: Record = {}, ) { return { code, display_name: displayName, difficulty, theory_target: ["humanistic"], demographics: { age_band: "20대" }, presenting_summary: `${displayName}의 주호소 요약`, voice_preset: null, source: "database", degraded: false, ...overrides, }; } const PERSONAS_BODY = [ makePersona("P1", "성하늘", "easy", { presenting_summary: "학업 스트레스와 수면 문제를 호소", }), makePersona("P2", "김도윤", "moderate", { theory_target: ["cbt"], demographics: { age_band: "30대" }, presenting_summary: "직장 내 대인관계 갈등을 호소", }), ]; // ── 회기 fixture ──────────────────────────────────────────────────────────── function makeSession( sessionId: string, overrides: Record = {}, ) { return { session_id: sessionId, 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, ...overrides, }; } const ACT_ID = "ucact001"; const REV_ID = "ucrev001"; const END_ID = "ucend001"; const ARC_ID = "ucarc001"; const S_ACT = makeSession(ACT_ID, { session_no: 4, status: "active", stage: "탐색", started_at: iso(1 * HOUR), ended_at: null, turn_count: 6, learner_turn_count: 3, client_turn_count: 3, }); const S_REV = makeSession(REV_ID, { session_no: 3, stage: "정리", started_at: iso(3 * HOUR), ended_at: iso(2.5 * HOUR), review_ready: true, turn_count: 10, learner_turn_count: 5, client_turn_count: 5, }); const S_END = makeSession(END_ID, { session_no: 2, persona_code: "P2", persona_name: "김도윤", stage: "라포", }); const S_ARC = makeSession(ARC_ID, { session_no: 1, stage: "개입", started_at: iso(8 * HOUR), ended_at: iso(7 * HOUR), archived: true, archived_at: iso(6 * HOUR), turn_count: 8, learner_turn_count: 4, client_turn_count: 4, }); const BASE_SESSIONS = [S_ACT, S_REV, S_END, S_ARC]; // 대시보드는 practice 통계(진행/리뷰/누적)와 history 카운트의 우선 소스라 // 회기 fixture에서 결정론으로 파생해 응답 모양을 실제 타입과 맞춘다. function makeDashboard(sessions: Array>) { const active = sessions.filter((s) => s.status === "active").length; const ended = sessions.filter((s) => s.status === "ended").length; const review = sessions.filter( (s) => s.status === "ended" && s.review_ready && !s.archived, ).length; const archived = sessions.filter((s) => s.archived).length; return { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: sessions.length, active_sessions: active, review_ready_sessions: review, completed_sessions: ended, archived_sessions: archived, learner_turns: 0, client_turns: 0, last_practiced_at: sessions.length ? iso(1 * HOUR) : 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: [], }; } async function installFixtures( page: Page, options: { personas?: Array>; sessions?: Array>; } = {}, ) { const personas = options.personas ?? PERSONAS_BODY; const sessions = options.sessions ?? BASE_SESSIONS; await routeUnmockedApi(page); await routeAuthedLearner(page); 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(makeDashboard(sessions))), ); } const personaListbox = (page: Page) => page.getByRole("listbox", { name: "연습 페르소나" }); test.describe("uc learner practice/history", () => { // usecase: 학습자가 연습 화면에서 다른 내담자를 고른 뒤 새 회기를 시작한다. test("페르소나를 선택하고 새 회기를 시작하면 해당 내담자 세션으로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const listbox = personaListbox(page); // 기록 보유 학습자 → 기본 선택은 API 순서 첫 사용 가능 페르소나(P1). await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute( "aria-selected", "true", ); await listbox.getByRole("option", { name: /P2/ }).click(); await expect(listbox.getByRole("option", { name: /P2/ })).toHaveAttribute( "aria-selected", "true", ); // 미리보기 제목·주호소가 선택 페르소나로 갱신된다. await expect(page.getByRole("heading", { name: "김도윤" })).toBeVisible(); await expect(page.locator(".lh-summary p")).toHaveText( "직장 내 대인관계 갈등을 호소", ); await page.getByRole("button", { name: "새 회기 시작" }).click(); await expect(page).toHaveURL(/\/learn\/session\/P2$/); }); // usecase: 기록이 없는 신규 학습자에게 기초 난도부터 추천하되, 사용 불가 // (degraded) 페르소나는 건너뛰고 다음 사용 가능한 기초 페르소나를 고른다. test("신규 학습자 기본 추천은 사용 불가 기초 페르소나를 건너뛴다", async ({ page, }) => { await installFixtures(page, { personas: [ makePersona("P9", "한겨울", "hard"), makePersona("P2", "김도윤", "easy", { degraded: true }), makePersona("P5", "박새봄", "easy"), ], sessions: [], }); await page.goto("/learn/practice"); const listbox = personaListbox(page); await expect(listbox.getByRole("option", { name: /P5/ })).toHaveAttribute( "aria-selected", "true", ); await expect(listbox.getByRole("option", { name: /P9/ })).toHaveAttribute( "aria-selected", "false", ); await expect(page.getByRole("heading", { name: "박새봄" })).toBeVisible(); }); // usecase: 신규 학습자에게 같은 난도가 여러 명이면 code 오름차순 첫 번째를 추천한다. test("신규 학습자 기본 추천은 난도 동률이면 코드 오름차순으로 고른다", async ({ page, }) => { await installFixtures(page, { // API 응답 순서는 PB가 먼저 — D3가 없으면 PB가 기본 선택된다. personas: [ makePersona("PB", "이가을", "easy"), makePersona("PA", "정봄이", "easy"), ], sessions: [], }); await page.goto("/learn/practice"); const listbox = personaListbox(page); await expect(listbox.getByRole("option", { name: /PA/ })).toHaveAttribute( "aria-selected", "true", ); await expect(listbox.getByRole("option", { name: /PB/ })).toHaveAttribute( "aria-selected", "false", ); }); // usecase: 사용 불가 페르소나는 목록에서 구분 표기되고 선택할 수 없다. test("사용 불가 페르소나는 비활성 표기되고 클릭해도 선택되지 않는다", async ({ page, }) => { await installFixtures(page, { personas: [ makePersona("P1", "성하늘", "easy"), makePersona("P3", "최다온", "moderate", { degraded: true }), ], sessions: [], }); await page.goto("/learn/practice"); const listbox = personaListbox(page); const degradedOption = listbox.getByRole("option", { name: /P3/ }); await expect(degradedOption).toBeDisabled(); await expect(degradedOption).toHaveAttribute("aria-disabled", "true"); await expect(degradedOption.locator(".lh-persona__flag")).toHaveText( "사용 불가", ); await expect(degradedOption).toContainText( "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.", ); // 비활성 버튼 클릭 시도 후에도 선택은 P1을 유지한다. await degradedOption.click({ force: true }); await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute( "aria-selected", "true", ); await expect(degradedOption).toHaveAttribute("aria-selected", "false"); }); // usecase: 연습 화면에서 페르소나를 바꾸면 '기존 회기' 목록이 그 내담자 // 기록으로 갱신되어 이어가기/리뷰 판단을 돕는다. test("페르소나 선택에 따라 기존 회기 목록이 해당 내담자 기록으로 바뀐다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const recent = page.locator(".lh-activity__recent"); // 기본 선택 P1 — P1 회기 3건(진행·리뷰·보관). // head는 Kicker("기존 회기")와 카운트 span 두 개라 마지막 span을 읽는다. await expect(recent.locator(".lh-activity__head > span").last()).toHaveText( "P1 · 3회", ); await expect(recent.locator(".lh-session-card")).toHaveCount(3); await expect(recent).toContainText("성하늘"); await expect(recent).not.toContainText("김도윤"); await personaListbox(page).getByRole("option", { name: /P2/ }).click(); await expect(recent.locator(".lh-activity__head > span").last()).toHaveText( "P2 · 1회", ); await expect(recent.locator(".lh-session-card")).toHaveCount(1); await expect(recent).toContainText("김도윤"); }); // usecase: 학습자가 연습 화면 상단 통계로 진행/리뷰/누적 현황을 한눈에 본다. test("연습 화면 통계가 진행 중·리뷰 가능·누적 회기 수를 표시한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/practice"); const stats = page.locator(".lh-activity__stats > span"); await expect(stats).toHaveCount(3); await expect(stats.nth(0)).toContainText("진행 중"); await expect(stats.nth(0).locator("b")).toHaveText("1"); await expect(stats.nth(1)).toContainText("리뷰 가능"); await expect(stats.nth(1).locator("b")).toHaveText("1"); await expect(stats.nth(2)).toContainText("누적 회기"); await expect(stats.nth(2).locator("b")).toHaveText("4"); }); // usecase: 기록 화면에서 각 회기 카드의 상태(진행 중/리뷰 가능/종료/보관됨)와 // 다음 행동 버튼(이어하기/리뷰/기록)을 구분해 읽는다. test("기록 카드가 상태별 라벨과 행동 버튼을 구분해 표시한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/history"); const cards = page.locator(".lh-session-list .lh-session-card"); await expect(cards).toHaveCount(4); // 정렬(ended_at ?? started_at 내림차순): 진행중 → 리뷰 → 종료 → 보관. const expectations = [ { status: "진행 중", bucket: "이어갈 회기", action: "이어하기" }, { status: "리뷰 가능", bucket: "AI 피드백 대기", action: "리뷰" }, { status: "종료", bucket: "종료 기록", action: "기록" }, { status: "보관됨", bucket: "보관 처리됨", action: "기록" }, ]; for (const [index, item] of expectations.entries()) { const card = cards.nth(index); await expect(card.locator(".lh-session-card__status b")).toHaveText( item.status, ); await expect(card.locator(".lh-session-card__status small")).toHaveText( item.bucket, ); await expect( card.getByRole("button", { name: item.action, exact: true }), ).toBeVisible(); } // 진행 중 카드에는 보관 버튼이 없고, 종료 카드에는 있다. await expect( cards.nth(0).getByRole("button", { name: "보관", exact: true }), ).toHaveCount(0); await expect( cards.nth(2).getByRole("button", { name: "보관", exact: true }), ).toBeVisible(); await expect( cards.nth(3).getByRole("button", { name: "복원", exact: true }), ).toBeVisible(); }); // usecase: 리뷰 준비된 기록 카드에서 '리뷰'를 눌러 회기 리뷰로 진입한다. test("기록 카드의 리뷰 버튼이 해당 회기 리뷰 화면으로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/history"); const revCard = page.locator(".lh-session-card", { hasText: "AI 피드백 대기", }); await expect(revCard).toHaveCount(1); await revCard.getByRole("button", { name: "리뷰", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`)); }); // usecase: 종료된 기록 카드에서 '다시 연습'을 눌러 같은 내담자로 새 회기를 연다. test("기록 카드의 다시 연습 버튼이 같은 내담자 새 회기로 이동한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn/history"); const endCard = page.locator(".lh-session-card", { hasText: "김도윤" }); await expect(endCard).toHaveCount(1); await endCard .getByRole("button", { name: "다시 연습", exact: true }) .click(); await expect(page).toHaveURL(/\/learn\/session\/P2$/); }); // usecase: 상태 필터 칩과 검색어를 함께 걸어 조건을 좁히고, 조건이 어긋나면 // 빈 결과 문구를 확인한 뒤 필터를 되돌려 결과를 다시 찾는다. test("검색어와 상태 필터 칩을 조합해 기록을 좁힌다", async ({ page }) => { await installFixtures(page); await page.goto("/learn/history"); const head = page.locator(".lh-history-main__head"); const chips = page.locator(".lh-history-filter"); const search = page.getByLabel("기록 검색"); // 진행 중 필터 + 종료 회기에만 있는 검색어 → 교집합 0건. await chips.getByRole("button", { name: "진행 중" }).click(); await search.fill("김도윤"); await expect(head).toContainText("0개"); await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible(); // 같은 검색어로 필터만 전체로 되돌리면 1건이 다시 보인다. await chips.getByRole("button", { name: "전체" }).click(); await expect(head).toContainText("1개"); await expect( page.locator(".lh-session-list .lh-session-card"), ).toContainText("김도윤"); // 검색은 상태 라벨("보관됨")로도 매칭된다. await search.fill("보관"); await expect(head).toContainText("1개"); await expect( page .locator(".lh-session-list .lh-session-card") .locator(".lh-session-card__status b"), ).toHaveText("보관됨"); }); // usecase: 기록 목록의 기본 정렬은 종료 시각 우선 최신순이라, 먼저 시작했어도 // 나중에 끝난 회기가 목록 맨 위로 온다. test("기록 목록은 종료 시각(없으면 시작 시각) 기준 최신순으로 정렬된다", async ({ page, }) => { await installFixtures(page, { sessions: [ // 시작은 가장 이르지만 종료가 가장 최신 → 1위여야 한다. makeSession("ucsortA1", { persona_code: "PA", persona_name: "정렬첫째", started_at: iso(10 * HOUR), ended_at: iso(1 * HOUR), }), // 진행 중(ended_at 없음) → started_at(2시간 전) 기준 2위. makeSession("ucsortB1", { persona_code: "PB", persona_name: "정렬둘째", status: "active", stage: "탐색", started_at: iso(2 * HOUR), ended_at: null, }), // 종료 3시간 전 → 3위. makeSession("ucsortC1", { persona_code: "PC", persona_name: "정렬셋째", started_at: iso(4 * HOUR), ended_at: iso(3 * HOUR), }), ], }); await page.goto("/learn/history"); const cards = page.locator(".lh-session-list .lh-session-card"); await expect(cards).toHaveCount(3); await expect(cards.nth(0)).toContainText("정렬첫째"); await expect(cards.nth(1)).toContainText("정렬둘째"); await expect(cards.nth(2)).toContainText("정렬셋째"); }); // usecase: 리뷰할 회기가 없는 상태에서 '리뷰 필요' 필터를 열면 검색어 없는 // 필터 전용 빈 문구를 본다. test("리뷰 필요 필터에 해당 기록이 없으면 전용 빈 문구를 보여준다", async ({ page, }) => { await installFixtures(page, { sessions: [S_ACT, S_END] }); await page.goto("/learn/history"); const overview = page.locator(".lh-history-overview"); await expect(overview.getByRole("button", { name: /리뷰 필요/ })).toContainText( "0", ); await page .locator(".lh-history-filter") .getByRole("button", { name: "리뷰 필요" }) .click(); await expect(page.locator(".lh-history-main__head")).toContainText("0개"); await expect( page.getByText("리뷰가 필요한 회기가 없습니다."), ).toBeVisible(); // 검색어가 있으면 문구가 검색 전용 문구로 바뀐다. await page.getByLabel("기록 검색").fill("성하늘"); await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible(); }); // usecase: 오래 연습한 학습자가 100개 기록을 끝까지 스크롤해 살펴본다. test("100개 기록도 전부 렌더링되고 끝까지 스크롤해도 가로 넘침이 없다", async ({ page, }) => { const bulk = Array.from({ length: 100 }, (_, i) => makeSession(`ucbulk${String(i).padStart(3, "0")}`, { session_no: 100 - i, persona_code: i % 2 === 0 ? "P1" : "P2", persona_name: i % 2 === 0 ? "성하늘" : "김도윤", stage: (["라포", "탐색", "개입", "정리"] as const)[i % 4], started_at: iso((i + 2) * HOUR), ended_at: iso((i + 2) * HOUR - 30 * 60 * 1000), }), ); await installFixtures(page, { sessions: bulk }); await page.goto("/learn/history"); await expect(page.locator(".lh-history-main__head")).toContainText("100개"); const cards = page.locator(".lh-session-list .lh-session-card"); await expect(cards).toHaveCount(100); await cards.nth(99).scrollIntoViewIfNeeded(); await expect(cards.nth(99)).toBeVisible(); await expectNoHorizontalOverflow(page); }); // usecase: 기록 카드에서 회기 시작 시각을 "N월 N일 오전/오후 HH:MM" 형식으로 // 읽고, 손상된 날짜 값은 깨진 문구 없이 조용히 비워진다. test("기록 카드가 날짜·시간을 한국어 형식으로 표기하고 잘못된 날짜는 비운다", async ({ page, }) => { await installFixtures(page, { sessions: [ makeSession("ucdate001", { persona_name: "날짜확인", started_at: iso(2 * HOUR), ended_at: iso(1.5 * HOUR), }), makeSession("ucdate002", { persona_name: "손상날짜", started_at: "not-a-date", ended_at: null, }), ], }); await page.goto("/learn/history"); const dated = page.locator(".lh-session-card", { hasText: "날짜확인" }); const dateSpan = dated.locator(".lh-session-card__body > p > span").nth(1); // sessionDateLabel: Intl ko-KR { month:short, day:numeric, hour/minute 2-digit }. await expect(dateSpan).toHaveText( /^\d{1,2}월 \d{1,2}일 (오전|오후) \d{2}:\d{2}$/, ); // 단계·턴 표기와 날짜가 한 줄에 함께 놓인다. await expect( dated.locator(".lh-session-card__body > p > span").nth(0), ).toHaveText("라포 단계 · 4턴"); const broken = page.locator(".lh-session-card", { hasText: "손상날짜" }); await expect( broken.locator(".lh-session-card__body > p > span").nth(1), ).toHaveText(""); await expect(broken).not.toContainText("Invalid"); }); });