/* ===================================================================== uc-session-case-launch.spec.ts — 새 사례 / 이어서 진행 시작 계약 목적: 같은 NPC라도 학습자가 완전히 새 사례를 시작할지, 기존 사례를 이어갈지 명시적으로 선택한다. 이어가기는 누적 회기·턴·시간을 먼저 보여 주고, 기억 요약은 접힌 상태에서 사용자가 열 때만 요청한다. 전부 route fixture 기반이다. 실제 DB·AI 엔진의 사례 기억 생성은 검증하지 않으며, Home의 URL launch intent와 Session의 POST 계약만 고정한다. ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; const CONTINUE_CASE_ID = "11111111-1111-4111-8111-111111111111"; const PREVIOUS_CASE_ID = "55555555-5555-4555-8555-555555555555"; const FRESH_CASE_ID = "22222222-2222-4222-8222-222222222222"; const CONTINUE_SESSION_ID = "33333333-3333-4333-8333-333333333333"; const FRESH_SESSION_ID = "44444444-4444-4444-8444-444444444444"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } const PERSONA = { code: "P1", display_name: "민서(청소년 우울)", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "10대" }, presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습", voice_preset: "soft-young-fem", source: "database", degraded: false, }; function dashboardFixture() { return { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 3, active_sessions: 0, review_ready_sessions: 0, completed_sessions: 3, archived_sessions: 0, learner_turns: 9, client_turns: 9, last_practiced_at: "2026-08-31T09:00:00+09:00", }, 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 currentCaseFixture() { return { cases: [ { case_id: CONTINUE_CASE_ID, persona_code: "P1", persona_name: "민서", last_session_no: 3, progress: { total_sessions: 3, completed_sessions: 3, total_turns: 18, total_duration_seconds: 5_400, active_session_id: null, active_session_no: null, active_started_at: null, last_activity_at: "2026-08-31T09:00:00+09:00", }, }, { case_id: PREVIOUS_CASE_ID, persona_code: "P1", persona_name: "민서", last_session_no: 2, progress: { total_sessions: 2, completed_sessions: 2, total_turns: 10, total_duration_seconds: 3_600, active_session_id: null, active_session_no: null, active_started_at: null, last_activity_at: "2026-08-20T09:00:00+09:00", }, }, ], }; } function memoryFixture() { return { case_id: CONTINUE_CASE_ID, memory_available: true, latest_session_digest: "지난 회기에서 수면 문제를 먼저 다뤘습니다.", case_digest: "학업 부담과 무기력의 연결을 탐색 중입니다.", open_threads: ["수면 리듬을 어떻게 조절할지"], pinned_facts: ["기말고사 기간에는 불안이 커집니다."], }; } interface LaunchFixture { memoryRequestCount: () => number; startRequests: Array>; } /** catch-all을 먼저 등록하고, 실제 fixture endpoint를 뒤에 두어 LIFO 우선순위를 고정한다. */ async function installLaunchFixture(page: Page): Promise { let memoryRequests = 0; const startRequests: Array> = []; await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", email: "case.launch@hs.ac.kr", display_name: "사례 시작 검증 학습자", role: "learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: "2026-08-01T00:00:00+09:00", onboarding_completed_at: "2026-08-01T00:00:00+09:00", nickname: "사례 시작 검증 학습자", self_introduction: "", avatar_url: "", }), ), ); await page.route("**/api/personas", (route) => route.fulfill(jsonRoute([PERSONA]))); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } const request = (route.request().postDataJSON() ?? {}) as Record; startRequests.push(request); const isFresh = request.start_mode === "fresh"; await route.fulfill( jsonRoute( { session_id: isFresh ? FRESH_SESSION_ID : CONTINUE_SESSION_ID, case_id: isFresh ? FRESH_CASE_ID : CONTINUE_CASE_ID, persona_id: "persona-p1", persona_version: 1, session_no: isFresh ? 1 : 4, stage: "라포", effective_openness: 0.2, recall_summary: isFresh ? null : "압축된 기존 사례 기억", degraded: false, started_at: "2026-08-31T10:00:00+09:00", goal_stages: request.goal_stages ?? ["라포"], duration_limit_seconds: 3_600, warning_before_end_seconds: 600, learner_feedback_enabled: true, start_mode: isFresh ? "fresh" : "continue", }, 201, ), ); }); await page.route("**/api/sessions/dashboard", (route) => route.fulfill(jsonRoute(dashboardFixture())), ); await page.route("**/api/sessions/cases?persona_code=P1", (route) => route.fulfill(jsonRoute(currentCaseFixture())), ); await page.route(`**/api/sessions/cases/${CONTINUE_CASE_ID}/memory`, (route) => { memoryRequests += 1; return route.fulfill(jsonRoute(memoryFixture())); }); await page.route("**/api/users/me/prepost-measures**", (route) => route.fulfill( jsonRoute({ pilot_id: "phase3-pilot-draft", instrument_version: "fixture", measures: [], complete_pre_count: 0, complete_post_count: 0, updated_at: null, }), ), ); await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: true, reason: null })), ); await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}`, (route) => route.fulfill(sessionDetailFixture(CONTINUE_SESSION_ID, CONTINUE_CASE_ID, 4)), ); await page.route(`**/api/sessions/${FRESH_SESSION_ID}`, (route) => route.fulfill(sessionDetailFixture(FRESH_SESSION_ID, FRESH_CASE_ID, 1)), ); await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await page.route(`**/api/sessions/${FRESH_SESSION_ID}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await page.route("**/api/sessions/*/live-coach", (route) => route.fulfill( jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [], }), ), ); return { memoryRequestCount: () => memoryRequests, startRequests }; } function sessionDetailFixture(sessionId: string, caseId: string, sessionNo: number) { return jsonRoute({ session_id: sessionId, case_id: caseId, persona_id: "persona-p1", persona_version: 1, persona_code: "P1", persona_name: "민서", session_no: sessionNo, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.2, started_at: "2026-08-31T10:00:00+09:00", ended_at: null, review_ready: false, turns: [], goal_stages: ["라포"], duration_limit_seconds: 3_600, warning_before_end_seconds: 600, progress: null, }); } function launchButton(page: Page, label: string) { return page .locator(".lh-actions") .getByRole("button", { name: label, exact: true }) .last(); } function launchModeLabel(page: Page, label: string) { return page.locator(".lh-launch-mode label").filter({ hasText: label }); } test.describe("uc session case launch", () => { test("이어가기 통계와 접힌 기억을 확인하고 두 방식의 launch URL을 구분한다", async ({ page, }) => { const fixture = await installLaunchFixture(page); await page.goto("/learn/practice"); const freshMode = page.getByRole("radio", { name: "완전히 새로 시작" }); const continueMode = page.getByRole("radio", { name: "이어서 진행" }); const continuity = page.locator(".lh-continuity"); // 기본은 새 사례다. 기존 기억 endpoint는 foldout을 열기 전까지 요청하지 않는다. await expect(freshMode).toBeChecked(); await expect(continuity).toContainText("새 사례"); await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0); expect(fixture.memoryRequestCount()).toBe(0); await expect(continueMode).toBeEnabled(); await launchModeLabel(page, "이어서 진행").click(); await expect(continueMode).toBeChecked(); await expect(continuity).toContainText("다음 4회기"); await expect(continuity).toContainText("3회기"); await expect(continuity).toContainText("18턴"); await expect(continuity).toContainText("1시간 30분"); const memory = continuity.locator("details.lh-continuity__memory"); await expect(memory).not.toHaveAttribute("open", ""); expect(fixture.memoryRequestCount()).toBe(0); await memory.locator("summary").click(); await expect(memory).toHaveAttribute("open", ""); await expect(memory).toContainText("지난 회기에서 수면 문제를 먼저 다뤘습니다."); await expect(memory).toContainText("수면 리듬을 어떻게 조절할지"); await expect.poll(fixture.memoryRequestCount).toBe(1); await launchButton(page, "이어서 진행").click(); await expect(page).toHaveURL( new RegExp(`/learn/session/P1\\?continuity=continue&case=${CONTINUE_CASE_ID}$`), ); await page.goBack(); await expect(page.locator(".lh-continuity")).toBeVisible(); await launchModeLabel(page, "완전히 새로 시작").click(); await expect(freshMode).toBeChecked(); await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0); await launchButton(page, "완전히 새로 시작").click(); await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/); }); test("복수의 종료 사례에서는 고른 사례의 통계와 exact case URL을 사용한다", async ({ page, }) => { await installLaunchFixture(page); await page.goto("/learn/practice"); await launchModeLabel(page, "이어서 진행").click(); const continuity = page.locator(".lh-continuity"); const caseSelect = continuity.getByRole("combobox", { name: "이어서 진행할 사례", }); await expect(caseSelect).toHaveValue(CONTINUE_CASE_ID); await caseSelect.selectOption(PREVIOUS_CASE_ID); await expect(caseSelect).toHaveValue(PREVIOUS_CASE_ID); await expect(continuity).toContainText("다음 3회기"); await expect(continuity).toContainText("2회기"); await expect(continuity).toContainText("10턴"); await expect(continuity).toContainText("1시간"); await launchButton(page, "이어서 진행").click(); await expect(page).toHaveURL( new RegExp(`/learn/session/P1\\?continuity=continue&case=${PREVIOUS_CASE_ID}$`), ); }); for (const launch of [ { label: "새 사례", search: "?continuity=fresh", expected: { start_mode: "fresh", case_id: null }, }, { label: "이어서 진행", search: `?continuity=continue&case=${CONTINUE_CASE_ID}`, expected: { start_mode: "continue", case_id: CONTINUE_CASE_ID }, }, ]) { test(`${launch.label} 선택값을 회기 시작 POST 본문으로 전달한다`, async ({ page }) => { const fixture = await installLaunchFixture(page); await page.goto(`/learn/session/P1${launch.search}`); await expect(page.locator(".sx-page--prestart")).toBeVisible(); await page.getByRole("button", { name: "회기 시작", exact: true }).click(); await expect.poll(() => fixture.startRequests.length).toBe(1); expect(fixture.startRequests[0]).toMatchObject({ persona_code: "P1", ...launch.expected, }); }); } });