/* ===================================================================== uc-session-prestart.spec.ts — 유스케이스: 회기 사전 설정(목표 선택) 여정 목적: 학습자가 /learn/session/:personaCode 프리스타트 화면에서 · 내담자(페르소나) 정보·난이도를 확인하고 · 이번 회기 목표(1~4개)를 선택/해제하고 · 이론모드를 고른 뒤 · "회기 시작"으로 active 회기에 진입하는 실제 사용자 행동을 검증한다. 근거: apps/web/src/pages/Session.tsx 프리스타트 렌더(sx-prestart 블록, toggleGoal 1~4개 자유 선택, canStartSession/selectedGoals.length 게이트), src/lib/practiceLaunchIntent.ts (G4 처방 연습 deliberate / G5 전이 검증 transfer launch intent 파싱·URL 보존), src/lib/personaViewModel.ts (DIFFICULTY_LABEL, isUsablePersona). 중복 회피: full-sweep-session.spec.ts(동의 게이트·시간 알람·active 상호작용), self-directed-learning-loop.spec.ts(deliberate replay 처방 흐름·목표 추가 선택 1건)와 겹치지 않도록 목표 0개 차단·전체 선택·해제 토글·키보드 조작· transfer(G5) intent·잘못된 intent 차단 각도로 검증한다. 전부 route fixture 기반이며 실제 AI 엔진 턴 생성 없음. 모바일(≤720px)은 진행 초점 패널·칩·힌트 문구를 의도적으로 숨기므로 (session.css @media max-width:720px) 해당 어설션은 desktop 전용으로 가드한다. ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; const FIXTURE_SESSION_ID = "77777777-7777-4777-8777-777777777777"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } interface PrestartFixture { startRequests: Array>; } /** 프리스타트 여정용 route fixture — catch-all 먼저, 개별 fixture 나중(나중 등록 우선). */ async function routePrestartFixture(page: Page): Promise { 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: "00000000-0000-0000-0000-00000ucps301", email: "prestart.learner@hs.ac.kr", display_name: "프리스타트 학습자", role: "learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: Math.floor(Date.now() / 1000), onboarding_completed_at: Math.floor(Date.now() / 1000), nickname: "프리스타트 학습자", self_introduction: "회기 사전 설정 유스케이스 검증용 학습자입니다.", avatar_url: "", }), ), ); 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/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/voice/health", (route) => route.fulfill(jsonRoute({ available: true, reason: null })), ); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } const body = (route.request().postDataJSON() ?? {}) as Record; startRequests.push(body); await route.fulfill( jsonRoute( { session_id: FIXTURE_SESSION_ID, case_id: "uc-prestart-case-001", session_no: 1, stage: "라포", effective_openness: 0.21, recall_summary: null, degraded: false, goal_stages: body.goal_stages ?? ["라포", "탐색"], duration_limit_seconds: 3600, warning_before_end_seconds: 600, }, 201, ), ); }); // 시작 직후 URL 전환은 justStartedSessionRef로 재로딩을 건너뛰지만, // 안전망으로 재개 스냅샷도 결정론적으로 응답한다. await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}`, (route) => route.fulfill( jsonRoute({ session_id: FIXTURE_SESSION_ID, case_id: "uc-prestart-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, }), ), ); await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}/alliance-pulses`, (route) => route.fulfill( jsonRoute({ items: [ { pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab", 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/${FIXTURE_SESSION_ID}/live-coach`, (route) => { if (route.request().method() === "GET") { route.fulfill( jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [], }), ); return; } route.fulfill(jsonRoute({ detail: "not used in prestart usecases" }, 404)); }); return { startRequests }; } function goalsGroup(page: Page) { return page.getByRole("group", { name: "이번 회기 목표 선택" }); } function startButton(page: Page) { return page.getByRole("button", { name: "회기 시작" }); } async function gotoPrestart(page: Page, search = "") { await page.goto(`/learn/session/P1${search}`); await expect(page.locator(".sx-page--prestart")).toBeVisible(); } // G5 전이 검증(transfer) launch intent 쿼리 — practiceLaunchSearch와 동일한 파라미터 구성. const TRANSFER_LAUNCH_SEARCH = "?launch=transfer" + "&prescription=rx-transfer-001" + "&source_session=88888888-8888-4888-8888-888888888888" + "&criterion=competency.empathic_reflection" + "&novelty=unseen_transfer" + "&mode=counterevidence_forecast" + "&suite=suite-empathy-01" + "&trial=trial-empathy-01a"; // G4 처방 연습(deliberate) launch intent 쿼리 — self-directed 스펙의 replay와 겹치지 않게 // difficulty_ladder 모드를 쓴다. const DELIBERATE_LAUNCH_SEARCH = "?launch=deliberate" + "&prescription=rx-ladder-001" + "&source_session=99999999-9999-4999-8999-999999999999" + "&criterion=competency.open_question" + "&novelty=familiar" + "&mode=difficulty_ladder"; test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { // usecase: 학습자가 프리스타트 화면에서 내담자 이름·호소 문제·난이도를 확인한다 test("시작 전 화면에서 내담자 정보와 고난도 표시를 확인한다", async ({ page }, testInfo) => { await routePrestartFixture(page); await gotoPrestart(page); await expect( page.getByRole("heading", { name: "민서님과의 회기를 시작할까요?" }), ).toBeVisible(); const facts = page.locator(".sx-prestart__facts"); await expect(facts).toContainText("호소"); await expect(facts).toContainText("자퇴와 무기력감을 둘러싼 상담 연습"); await expect(facts).toContainText("난도"); await expect(facts).toContainText("고난도"); await expect(facts).toContainText("인간중심"); if (!testInfo.project.name.includes("mobile")) { // 데스크톱: 아바타 카드의 내담자 요약 + 태그 칩(P1·고난도)까지 확인한다. const caseCard = page.locator(".sx-prestart__case"); await expect(caseCard).toContainText("민서"); await expect(caseCard).toContainText("교육용 가상 내담자"); const chips = page.locator(".sx-prestart__chips"); await expect(chips).toContainText("P1"); await expect(chips.locator("span.is-clay")).toHaveText("고난도"); } }); // usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다 test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({ page, }, testInfo) => { await routePrestartFixture(page); await gotoPrestart(page); const settingsSummary = page.locator("details.sx-prestart__settings > summary"); await expect(settingsSummary).toContainText("인간중심, 라포 · 탐색"); const goals = goalsGroup(page); await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", "true", ); await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute( "aria-pressed", "true", ); await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute( "aria-pressed", "false", ); if (!testInfo.project.name.includes("mobile")) { const plan = page.locator(".sx-prestart__plan"); await expect(plan).toContainText("2개 선택"); await expect(plan).toContainText("라포 · 탐색"); await expect(plan).toContainText("60분 회기"); await expect(plan).toContainText("종료 10분 전 알림"); } }); // usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다 test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({ page, }, testInfo) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); await expect(startButton(page)).toBeEnabled(); await goals.getByRole("button", { name: /라포/ }).click(); await goals.getByRole("button", { name: /탐색/ }).click(); await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", "false", ); await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute( "aria-pressed", "false", ); await expect(startButton(page)).toBeDisabled(); await expect( page.locator("details.sx-prestart__settings > summary"), ).toContainText("목표 선택 필요"); if (!testInfo.project.name.includes("mobile")) { await expect( page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."), ).toBeVisible(); await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요"); } }); // usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다 test("목표를 다시 1개 선택하면 시작 차단이 풀린다", async ({ page }) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); await goals.getByRole("button", { name: /라포/ }).click(); await goals.getByRole("button", { name: /탐색/ }).click(); await expect(startButton(page)).toBeDisabled(); await goals.getByRole("button", { name: /개입/ }).click(); await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute( "aria-pressed", "true", ); await expect(startButton(page)).toBeEnabled(); await expect( page.locator("details.sx-prestart__settings > summary"), ).toContainText("인간중심, 개입"); }); // usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다 test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); const goalButtons = goals.getByRole("button"); // 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약. await expect(goalButtons).toHaveCount(4); await goals.getByRole("button", { name: /개입/ }).click(); await goals.getByRole("button", { name: /정리/ }).click(); for (const name of [/라포/, /탐색/, /개입/, /정리/]) { await expect(goals.getByRole("button", { name })).toHaveAttribute( "aria-pressed", "true", ); } await expect( page.locator("details.sx-prestart__settings > summary"), ).toContainText("라포 · 탐색 · 개입 · 정리"); await expect(startButton(page)).toBeEnabled(); if (!testInfo.project.name.includes("mobile")) { await expect(page.locator(".sx-prestart__plan")).toContainText("4개 선택"); } }); // usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다 test("선택된 목표를 다시 누르면 해제되고 요약이 갱신된다", async ({ page }) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); const exploreGoal = goals.getByRole("button", { name: /탐색/ }); await expect(exploreGoal).toHaveAttribute("aria-pressed", "true"); await exploreGoal.click(); await expect(exploreGoal).toHaveAttribute("aria-pressed", "false"); await expect( page.locator("details.sx-prestart__settings > summary"), ).toContainText("인간중심, 라포"); // 나머지 기본 목표는 그대로 유지된다. await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", "true", ); await expect(startButton(page)).toBeEnabled(); }); // usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다 test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({ page, }, testInfo) => { await routePrestartFixture(page); await gotoPrestart(page); const theory = page.getByRole("group", { name: "이론모드 선택" }); await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute( "aria-pressed", "true", ); await theory.getByRole("button", { name: /CBT/ }).click(); await expect(theory.getByRole("button", { name: /CBT/ })).toHaveAttribute( "aria-pressed", "true", ); await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute( "aria-pressed", "false", ); await expect( page.locator("details.sx-prestart__settings > summary"), ).toContainText("CBT, 라포 · 탐색"); if (!testInfo.project.name.includes("mobile")) { await expect(page.locator(".sx-prestart__plan")).toContainText( "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.", ); } }); // usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다 test("회기 시작을 누르면 active 화면으로 전환되고 선택한 목표가 요청에 실린다", async ({ page, }) => { const fixture = await routePrestartFixture(page); await gotoPrestart(page); // 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증). await goalsGroup(page).getByRole("button", { name: /탐색/ }).click(); await startButton(page).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); await expect(page).toHaveURL(new RegExp(`/learn/session/${FIXTURE_SESSION_ID}$`)); expect(fixture.startRequests).toHaveLength(1); expect(fixture.startRequests[0]).toMatchObject({ persona_code: "P1", theory_mode: "humanistic", goal_stages: ["라포"], }); // active 전환 뒤 목표 단계에 '목표' 배지가 붙는다(시작 응답의 goal_stages 반영). await expect(page.locator(".sx-ph.is-goal")).toHaveCount(1); }); // usecase: 학습자가 마우스 없이 키보드만으로 목표를 선택/해제한다 test("키보드 Space·Enter로 목표를 토글할 수 있다", async ({ page }) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); const interveneGoal = goals.getByRole("button", { name: /개입/ }); await expect(interveneGoal).toHaveAttribute("aria-pressed", "false"); await interveneGoal.focus(); await expect(interveneGoal).toBeFocused(); await page.keyboard.press("Space"); await expect(interveneGoal).toHaveAttribute("aria-pressed", "true"); await page.keyboard.press("Enter"); await expect(interveneGoal).toHaveAttribute("aria-pressed", "false"); // 키보드 해제 뒤에도 남은 목표로 시작은 계속 가능하다. await expect(startButton(page)).toBeEnabled(); }); // usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다 test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({ page, }) => { await routePrestartFixture(page); await gotoPrestart(page, DELIBERATE_LAUNCH_SEARCH); await expect( page.getByRole("heading", { name: "처방 연습 · 난도 사다리" }), ).toBeVisible(); const context = page.locator(".sx-practice-launch-context"); await expect(context).toContainText("익숙한 장면"); await expect(context).toContainText("성공 기준 개방형 질문"); await expect(context).toContainText("원본 회기 참조"); await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다"); // 내부 식별자는 학습자 본문에 노출하지 않는다. await expect(page.locator(".sx-page--prestart")).not.toContainText( "competency.open_question", ); // 처방 진입에서는 회기 설정 상세가 접힌 채 시작 행동에 집중한다. const settings = page.locator("details.sx-prestart__settings.is-prescribed"); await expect(settings).not.toHaveAttribute("open", ""); await expect(startButton(page)).toBeEnabled(); }); // usecase: 전이 검증(G5 transfer)에서 넘어온 학습자가 전이 과제 맥락을 확인한다 test("G5 전이 검증 launch intent가 처음 보는 장면 맥락으로 표시된다", async ({ page }) => { await routePrestartFixture(page); await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH); await expect( page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }), ).toBeVisible(); const context = page.locator(".sx-practice-launch-context"); await expect(context).toContainText("처음 보는 장면"); await expect(context).toContainText("성공 기준 공감적 반영"); await expect(context).toContainText("전이 과제 출처 확인됨"); await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다"); await expect(page.locator(".sx-page--prestart")).not.toContainText( "competency.empathic_reflection", ); await expect(startButton(page)).toBeEnabled(); }); // usecase: 학습자가 전이 검증 처방으로 회기를 시작해도 출처 쿼리가 URL에 그대로 남는다 test("launch intent 쿼리가 회기 시작 후에도 URL에 유지된다", async ({ page }) => { const fixture = await routePrestartFixture(page); await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH); await startButton(page).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); await expect(page).toHaveURL( new RegExp(`/learn/session/${FIXTURE_SESSION_ID}\\?`), ); const url = new URL(page.url()); expect(url.searchParams.get("launch")).toBe("transfer"); expect(url.searchParams.get("mode")).toBe("counterevidence_forecast"); expect(url.searchParams.get("novelty")).toBe("unseen_transfer"); expect(url.searchParams.get("suite")).toBe("suite-empathy-01"); expect(url.searchParams.get("trial")).toBe("trial-empathy-01a"); expect(url.searchParams.get("source_session")).toBe( "88888888-8888-4888-8888-888888888888", ); expect(fixture.startRequests).toHaveLength(1); // active 화면도 처방 모드를 유지한다(data-practice-mode 계약). await expect(page.locator(".sx-page--active")).toHaveAttribute( "data-practice-mode", "counterevidence_forecast", ); }); // usecase: 조작된/깨진 처방 링크로 진입한 학습자는 일반 회기로 축약되지 않고 차단 안내를 받는다 test("검증 불가한 launch 쿼리는 오류 안내와 함께 회기 시작을 차단한다", async ({ page }) => { await routePrestartFixture(page); // prescription 파라미터가 빠진 deliberate intent — parsePracticeLaunchIntent가 null을 반환. await gotoPrestart( page, "?launch=deliberate&source_session=99999999-9999-4999-8999-999999999999" + "&criterion=competency.open_question&novelty=familiar&mode=replay", ); await expect( page.getByRole("heading", { name: "연습 처방의 출처를 다시 확인해 주세요." }), ).toBeVisible(); await expect( page.getByRole("alert").filter({ hasText: "연습 처방의 출처를 검증할 수 없습니다. 일반 회기로 축약하지 않았습니다.", }), ).toBeVisible(); await expect(startButton(page)).toBeDisabled(); }); // usecase: 학습자가 프리스타트에서 마음을 바꿔 브라우저 뒤로가기로 학습자 홈에 돌아간다 test("뒤로가기로 프리스타트를 떠나 학습자 홈으로 복귀한다", async ({ page }) => { await routePrestartFixture(page); await page.goto("/learn"); await expect(page).toHaveURL(/\/learn$/); await gotoPrestart(page); await expect(startButton(page)).toBeVisible(); await page.goBack(); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".sx-page--prestart")).toHaveCount(0); await expect(startButton(page)).toHaveCount(0); }); });