/* ===================================================================== uc-session-coach-voice.spec.ts — AI 튜터 코칭·음성 UI 유스케이스 E2E 목적: 회기 화면의 AI 코치(피드백 모드·코칭 기회)와 음성 UI(AI 합성 음성 고지· 마이크 게이트·Alt+M·텍스트 대안)를 실제 사용자 여정 기준으로 검증한다. 근거: apps/web/src/pages/Session.tsx — AI_VOICE_DISCLOSURE(100~101, 2782~2785 프리스타트, 3652~3695 컨트롤바), feedbackMode segmented(3712~3739, 기본 "ambient" 432), coach 카드(3390~3483), 마이크 블록(3641~3690), Alt+M 단축키(2158~2163, 입력 중 무시), 음성 가용성 게이트(1195~1196). 주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음, getUserMedia/마이크 캡처 0회 계약 포함. 중복 회피: session-mvp.spec.ts(코칭 정상/열화/quota 갱신·음성 실패 상태), voice.spec.ts(WS 인증 경계), voice-success.spec.ts(캐스케이드 성공 경로). ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; const SESSION_ID = "66666666-6666-4666-8666-666666666666"; const DISCLOSURE = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다."; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } function sseTurnBody(tokens: string[], done: Record): string { const lines: string[] = []; for (const token of tokens) lines.push("event: token", `data: ${token}`, ""); lines.push("event: done", `data: ${JSON.stringify(done)}`, ""); return lines.join("\n"); } interface CoachVoiceOptions { coachRemaining?: number; coachMax?: number; } async function routeCoachVoiceSession(page: Page, options: CoachVoiceOptions = {}) { const { coachRemaining = 3, coachMax = 3 } = options; const startedAt = new Date(Date.now() - 120_000); // getUserMedia 호출 계수기 — 어떤 흐름에서도 동의·직접 조작 전 마이크가 열리면 안 된다. await page.addInitScript(() => { (window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls = 0; const devices = navigator.mediaDevices; if (devices && devices.getUserMedia) { const original = devices.getUserMedia.bind(devices); devices.getUserMedia = (constraints: MediaStreamConstraints) => { (window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls += 1; return original(constraints); }; } }); // catch-all 을 먼저 — 나중 등록이 우선이라 아래 개별 fixture 가 이긴다. 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-0000000ucv01", email: "uc.voice@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/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/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, }, ]), ), ); // 음성 provider 미설정 — 마이크 게이트/텍스트 대안 경로 검증용. await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: false, reason: "provider key not configured" })), ); await page.route(`**/api/sessions/${SESSION_ID}`, (route) => route.fulfill( jsonRoute({ session_id: SESSION_ID, case_id: "uc-voice-case-001", persona_code: "P1", persona_name: "민서", session_no: 1, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.34, started_at: startedAt.toISOString(), ended_at: null, review_ready: false, turns: [ { speaker: "learner", text: "이번 주는 어떻게 지냈는지 이야기해 줄 수 있을까요?", turn_seq: 1, created_at: new Date(startedAt.getTime() + 10_000).toISOString(), }, { speaker: "client", text: "그냥 계속 피곤하고, 학교 생각만 하면 답답했어요.", turn_seq: 2, created_at: new Date(startedAt.getTime() + 20_000).toISOString(), }, ], }), ), ); await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { await route.fulfill( jsonRoute( { pulse_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", status: "awaiting_agents" }, 202, ), ); return; } await route.fulfill( jsonRoute({ items: [ { pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", checkpoint: "pre", status: "ready", learner_locked_at: startedAt.toISOString(), revealed_at: startedAt.toISOString(), error_code: null, self_scores: { goal: 0.5, task: 0.5, bond: 0.5 }, measurements: [], }, ], }), ); }); await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, async (route) => { if (route.request().method() === "POST") { await route.fulfill( jsonRoute({ status: "ready", tone: "pos", focus: "emotion", title: "공감과 탐색", message: "내담자의 답답한 마음에 먼저 공감하고 구체적인 장면을 열린 질문으로 물어보세요.", next_utterance: "학교 생각만 하면 답답하다고 했는데, 어떤 부분이 제일 마음에 걸려요?", rationale: "감정 타당화 후 구체 촉발 탐색", quota: { remaining: Math.max(0, coachRemaining - 1), max: coachMax }, credit_events: [ { event_id: "c-001", session_id: SESSION_ID, turn_seq: 1, event_type: "use", delta: -1, balance: Math.max(0, coachRemaining - 1), reason: "live coaching request", created_at: new Date().toISOString(), }, ], }), ); return; } await route.fulfill( jsonRoute({ source: "database", quota: { remaining: coachRemaining, max: coachMax }, credit_events: [], events: [], }), ); }); } async function openSession(page: Page) { await page.goto(`/learn/session/${SESSION_ID}`); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); } function getUserMediaCalls(page: Page) { return page.evaluate( () => (window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls ?? 0, ); } /** 뷰포트에 따라 고지 노출 위치가 달라 "보이는 인스턴스 1개 이상"으로 판정한다. */ function visibleDisclosureCount(page: Page) { return page.evaluate((text) => { const nodes = Array.from(document.querySelectorAll("[role=note]")); return nodes.filter( (node) => node.textContent?.includes(text) && (node as HTMLElement).offsetParent !== null, ).length; }, DISCLOSURE); } test.describe("AI 코치·음성 UI — 고지·게이트·피드백 모드", () => { // usecase: 회기를 시작하기 전에 내담자 음성이 AI 합성음이라는 고지를 읽는다 test("프리스타트 화면에 AI 합성 음성 고지가 노출된다", async ({ page }) => { await routeCoachVoiceSession(page); await page.goto("/learn/session/P1"); await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible(); expect(await visibleDisclosureCount(page)).toBeGreaterThan(0); }); // usecase: 회기 진행 중에도 AI 합성 음성 고지가 계속 보인다 test("활성 회기 화면에 AI 합성 음성 고지가 상시 노출된다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); expect(await visibleDisclosureCount(page)).toBeGreaterThan(0); }); // usecase: 키보드 사용자는 마이크 단축키 안내(Alt+M)를 화면에서 확인할 수 있다 test("마이크 단축키 Alt M 안내가 마크업에 존재한다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); await expect( page.locator('[aria-label="마이크 단축키 Alt M"]'), ).toBeAttached(); }); // usecase: 음성 provider 가 준비되지 않았으면 마이크가 비활성화되어 헛클릭이 없다. // 주의: 가용성 사전 게이트(voiceHealth)는 "회기 시작" 직후에만 동작한다 // (Session.tsx 1195~1205) — resume 경로는 게이트를 태우지 않으므로 // 프리스타트 → 회기 시작 흐름으로 검증한다. test("음성 provider 미가용 시 마이크 버튼이 비활성화된다", async ({ page }) => { await routeCoachVoiceSession(page); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)); return; } await route.fulfill( jsonRoute({ session_id: SESSION_ID, case_id: "uc-voice-case-001", session_no: 1, stage: "라포", effective_openness: 0.34, goal_stages: ["라포", "탐색"], duration_limit_seconds: 3600, warning_before_end_seconds: 600, degraded: false, }), ); }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); // 게이트가 실제로 동작했음을 안내 문구로 확증한 뒤 비활성화를 판정한다. await expect(page.locator(".sx-mic-block__h")).toContainText( "음성 기능이 설정되지 않았습니다", ); const mic = page.locator(".sx-mic"); await expect(mic).toBeDisabled(); await expect(mic).toHaveAttribute("aria-pressed", "false"); }); // usecase: 회기 화면을 열었다는 이유만으로 마이크 권한이 요청되면 안 된다 test("회기 진입만으로는 getUserMedia 가 호출되지 않는다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); expect(await getUserMediaCalls(page)).toBe(0); }); // usecase: 입력창에 글을 쓰는 중의 Alt+M 은 마이크 토글로 오작동하지 않는다 test("입력창 포커스 중 Alt+M 은 무시되고 마이크는 열리지 않는다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); const composer = page.getByLabel("학습자 발화 입력"); await composer.click(); await composer.fill("마이크 단축키 문자를 입력 중입니다 m"); await page.keyboard.press("Alt+m"); expect(await getUserMediaCalls(page)).toBe(0); await expect(page.locator(".sx-mic")).toHaveAttribute("aria-pressed", "false"); }); // usecase: 음성이 안 되는 환경에서도 텍스트로 회기를 계속할 수 있다 test("음성 미가용 상태에서도 텍스트 턴이 정상 완료된다", async ({ page }) => { await routeCoachVoiceSession(page); await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => route.fulfill({ status: 200, contentType: "text/event-stream", body: sseTurnBody(["말해 줘서 ", "고마워요."], { session_id: SESSION_ID, stage: "라포", effective_openness: 0.4, turn_seq: 3, safety_flagged: false, }), }), ); await openSession(page); const composer = page.getByLabel("학습자 발화 입력"); await composer.fill("그 답답함이 몸에서는 어떻게 느껴졌는지 궁금해요."); await page.getByRole("button", { name: "보내기" }).click(); await expect( page.locator(".sx-utt.is-client").filter({ hasText: "말해 줘서 고마워요." }), ).toBeVisible(); await expect(composer).toBeEnabled(); }); // usecase: 코칭 모드를 켜면 코치 카드가 기본 안내와 함께 나타난다 test("코칭 모드 전환 시 코치 카드와 기본 안내가 나타난다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); await page.getByRole("button", { name: /^코칭/ }).click(); await expect(page.locator(".sx-coach-card")).toBeVisible(); await expect(page.locator(".sx-coach-card")).toContainText("AI 코치"); await expect( page.getByText("코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다."), ).toBeVisible(); }); // usecase: 남은 코칭 기회가 숫자와 도트로 함께 표시된다 test("코칭 기회 잔여 수가 도트·숫자·aria 라벨로 표기된다", async ({ page }) => { await routeCoachVoiceSession(page, { coachRemaining: 2, coachMax: 3 }); await openSession(page); const coachedToggle = page.getByRole("button", { name: "코칭 모드, 남은 기회 2개" }); await expect(coachedToggle).toBeVisible(); await coachedToggle.click(); const quota = page.locator(".sx-coach-quota"); await expect(quota).toContainText("코칭 기회"); await expect(quota).toContainText("2/3"); await expect(quota.locator(".sx-coach-quota__dots i.is-filled")).toHaveCount(2); }); // usecase: 코칭 기회를 다 쓰면 충전 조건이 안내된다 test("코칭 기회 소진 시 충전 조건 안내가 뜬다", async ({ page }) => { await routeCoachVoiceSession(page, { coachRemaining: 0, coachMax: 3 }); await openSession(page); await page.getByRole("button", { name: "코칭 모드, 남은 기회 0개" }).click(); await expect( page.getByText("코칭 기회를 모두 사용했습니다", { exact: false }).first(), ).toBeVisible(); await expect(page.locator(".sx-segmented__badge.is-empty")).toBeAttached(); }); // usecase: 몰입 모드를 켜면 실시간 신호 패널이 조용해진다. // 주의: ≤1180px 에서는 우측 진단 레일 전체가 숨겨지는 것이 계약이다 // (session.css 3407~3409) — 몰입 안내 문구는 데스크톱에서만 보인다. test("몰입 모드에서는 신호 패널 대신 몰입 안내가 뜬다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); await page.getByRole("button", { name: "몰입", exact: true }).click(); await expect(page.locator(".sx-page--active.sx-feedback-immersive")).toBeVisible(); await expect(page.locator(".sx-coach-card")).toHaveCount(0); const immersiveNote = page.getByText("몰입 모드", { exact: true }); if ((page.viewportSize()?.width ?? 0) > 1180) { await expect(immersiveNote).toBeVisible(); await expect( page.getByText("실시간 신호 없이 대화에만 집중합니다", { exact: false }), ).toBeVisible(); } else { await expect(immersiveNote).toBeHidden(); } }); // usecase: 상태 신호 모드는 신호를 "조용히 표시"로 유지한다 test("상태 신호 모드에서 조용한 신호 라벨이 표시된다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); await page.getByRole("button", { name: "상태 신호" }).click(); await expect(page.getByText("조용히 표시").first()).toBeAttached(); }); // usecase: 피드백 모드 그룹이 접근 가능한 이름을 갖는다 test("피드백 모드 segmented 그룹이 role=group 으로 노출된다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); const group = page.getByRole("group", { name: "피드백 모드" }); await expect(group).toBeVisible(); await expect(group.getByRole("button")).toHaveCount(3); }); // usecase: 마이크 버튼은 상태를 설명하는 접근 가능한 이름을 갖는다 test("마이크 버튼이 aria-pressed 와 설명 라벨을 갖는다", async ({ page }) => { await routeCoachVoiceSession(page); await openSession(page); const mic = page.locator(".sx-mic"); await expect(mic).toHaveAttribute("aria-pressed", "false"); const label = await mic.getAttribute("aria-label"); expect(label ?? "").not.toBe(""); }); // usecase: AI 코칭 버튼 클릭 시 버블 팝업이 노출되고 추천 칩 및 채팅 조언을 얻을 수 있다 test("AI 코칭 버튼 클릭 시 버블 팝업이 뜨고 채팅 질문과 추천 발화 적용이 동작한다", async ({ page }) => { await routeCoachVoiceSession(page, { coachRemaining: 3, coachMax: 3 }); await openSession(page); const triggerBtn = page.getByRole("button", { name: /^AI 코칭, 남은 기회/ }); await expect(triggerBtn).toBeVisible(); await expect(triggerBtn).toContainText("3"); // 1. 버블 팝업 열기 await triggerBtn.click({ force: true }); const popup = page.getByRole("dialog", { name: "AI 슈퍼바이저 코칭 버블" }); await expect(popup).toBeVisible(); await expect(popup).toContainText("AI 슈퍼바이저 코칭"); await expect(popup).toContainText("기회 3/3"); // 2. 추천 칩 확인 및 클릭 const recommendChip = popup.getByRole("button", { name: "💡 다음 추천 발화" }); await expect(recommendChip).toBeVisible(); await recommendChip.dispatchEvent("click"); // 3. 조언 메시지 및 추천 발화 노출 확인 await expect(popup.locator(".sx-coach-msg--learner")).toContainText("다음 추천 발화와 방향을 알려줘"); await expect(popup.locator(".sx-coach-msg--coach")).toBeVisible(); const insertBtn = popup.getByRole("button", { name: "발화창에 넣기" }); await expect(insertBtn).toBeAttached(); // 4. [발화창에 넣기] 클릭 시 하단 발화 입력창에 텍스트 반영 await insertBtn.dispatchEvent("click"); const composer = page.getByPlaceholder("학습자 발화를 입력하세요."); await expect(composer).not.toHaveValue(""); // 5. 직접 질문 채팅 입력 및 전송 const coachInput = popup.getByPlaceholder("코치에게 조언을 구해보세요... (Enter 전송)"); await coachInput.fill("내담자의 저항을 부드럽게 다루려면 어떻게 하나요?"); await popup.getByRole("button", { name: "코칭 질문 전송" }).dispatchEvent("click"); await expect(popup.locator(".sx-coach-msg--learner").last()).toContainText("내담자의 저항을 부드럽게 다루려면"); await expect(popup.locator(".sx-coach-msg--coach").last()).toBeVisible(); // 6. 팝업 닫기 버튼 클릭 시 닫힘 확인 await popup.getByRole("button", { name: "코칭 팝업 닫기" }).dispatchEvent("click"); await expect(popup).toBeHidden(); }); });