/* ===================================================================== avatar-session-speech.spec.ts — P1 세션 TTS 재생 ↔ v3(리노컷) 아바타 발화 연결 E2E(결정문 §8.5 "세션 연결" 테스트 계약). 목적: TTS 응답을 테스트 안에서 만든 WAV(유성 구간이 있는 톤버스트 여러 개)로 모킹해, 재생 중 v3 래퍼의 data-speech-source="audio"· data-viseme 순환을 확인하고, 재생이 끝나면 발화 속성이 정리되는지 본다. TTS 실패(500)면 data-speech-source="text"로 텍스트 타이밍 발화로 떨어지는지도 본다. 근거: apps/web/src/pages/Session.tsx playTtsAudio/speakTextClientTurn, apps/web/src/components/avatar/v3/ClientAvatarV3.tsx. 주의: 모든 API는 route fixture로 모킹한다(실제 AI 엔진·TTS 제공자 없음). 헤드리스 자동재생 정책 때문에 이 파일만 autoplay-policy를 느슨하게 연다(세션 범위, 다른 스펙에 영향 없음). ===================================================================== */ import { expect, test, type Locator, type Page, type Route } from "@playwright/test"; test.use({ launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] } }); const SESSION_ID = "66666666-6666-4666-8666-666666666666"; const LEARNER_TEXT = "요즘 많이 힘들었어요. 어떤 마음이 가장 크게 남아 있나요?"; const CLIENT_REPLY = "그냥요. 잠을 잘 못 자요. 아무 것도 하고 싶지 않아요."; 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"); } async function fulfillTurn(route: Route, tokens: string[], done: Record) { await route.fulfill({ status: 200, contentType: "text/event-stream", body: sseTurnBody(tokens, done) }); } function writeAsciiString(view: DataView, offset: number, text: string): void { for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i)); } /** * 유성 구간이 있는 톤버스트 여러 개로 테스트용 WAV를 만든다(16비트 PCM 모노). * decodeAudioData가 바로 디코드할 수 있다. 무음 구간(0)과 사인파 구간을 번갈아 * 넣어 speechEnvelope.ts의 유성 구간 검출(포락선 > max(0.02, 0.12·P95))이 * 서로 다른 구간 여러 개를 집어내게 한다. */ function buildToneBurstWav(): Buffer { const sampleRate = 16000; const amplitude = 0.7; const segments: Array<{ freq: number; durMs: number }> = [ { freq: 0, durMs: 150 }, { freq: 220, durMs: 320 }, { freq: 0, durMs: 180 }, { freq: 420, durMs: 280 }, { freq: 0, durMs: 180 }, { freq: 320, durMs: 300 }, { freq: 0, durMs: 220 }, ]; const samples: number[] = []; for (const seg of segments) { const count = Math.round((seg.durMs / 1000) * sampleRate); for (let i = 0; i < count; i++) { samples.push(seg.freq > 0 ? amplitude * Math.sin((2 * Math.PI * seg.freq * i) / sampleRate) : 0); } } const dataLength = samples.length * 2; const buffer = Buffer.alloc(44 + dataLength); const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); writeAsciiString(view, 0, "RIFF"); view.setUint32(4, 36 + dataLength, true); writeAsciiString(view, 8, "WAVE"); writeAsciiString(view, 12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); // PCM view.setUint16(22, 1, true); // mono view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); // byte rate(mono·16비트) view.setUint16(32, 2, true); // block align view.setUint16(34, 16, true); // bits per sample writeAsciiString(view, 36, "data"); view.setUint32(40, dataLength, true); for (let i = 0; i < samples.length; i++) { const clamped = Math.max(-1, Math.min(1, samples[i])); view.setInt16(44 + i * 2, Math.round(clamped * 32767), true); } return buffer; } async function routeSessionScreen(page: Page) { const startedAt = new Date(Date.now() - 60_000); // catch-all을 먼저 등록한다 — Playwright는 나중에 등록한 route가 이긴다. await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this fixture" }, 404))); await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "00000000-0000-0000-0000-0avatarspeech", email: "avatar-speech.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: "아바타 v3 발화 연결 E2E 검증용 학습자입니다.", 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: "서연(가명) · 고2 · 우울/자살사고", difficulty: "hard", theory_target: ["humanistic", "cbt"], demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" }, presenting_summary: "우울감과 자살사고 위험", voice_preset: null, source: "database", degraded: false, }, ]), ), ); await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: false, reason: "e2e fixture" })), ); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } await route.fulfill( jsonRoute( { session_id: SESSION_ID, case_id: "avatar-speech-case-001", session_no: 1, stage: "라포", effective_openness: 0.3, recall_summary: null, degraded: false, }, 201, ), ); }); await page.route(`**/api/sessions/${SESSION_ID}`, (route) => route.fulfill( jsonRoute({ session_id: SESSION_ID, case_id: "avatar-speech-case-001", persona_code: "P1", persona_name: "서연", session_no: 1, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.3, started_at: startedAt.toISOString(), ended_at: null, review_ready: false, turns: [], }), ), ); // 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상이 아니다. await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { await route.fulfill(jsonRoute({ pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 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`, (route) => route.fulfill(jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [] })), ); } function doneEvent(overrides: Record = {}) { return { session_id: SESSION_ID, stage: "라포", effective_openness: 0.42, turn_seq: 1, safety_flagged: false, ...overrides, }; } async function sendLearnerTurn(page: Page) { const input = page.getByLabel("학습자 발화 입력"); await input.fill(LEARNER_TEXT); await page.getByRole("button", { name: "보내기" }).click(); } /** 재생 중 data-viseme가 거친 서로 다른 값의 집합(관찰 상한 5초, 50ms 간격 표본). */ async function collectDistinctVisemes(locator: Locator, minDistinct: number): Promise> { const seen = new Set(); const deadline = Date.now() + 5_000; while (Date.now() < deadline) { const viseme = await locator.getAttribute("data-viseme"); if (viseme) seen.add(viseme); if (seen.size >= minDistinct) break; await new Promise((resolve) => setTimeout(resolve, 50)); } return seen; } test.describe("P1 세션 — v3 아바타 TTS 발화 연결", () => { test("TTS 오디오 재생 중 립싱크가 돌고, 끝나면 발화 속성이 정리된다", async ({ page }) => { await routeSessionScreen(page); await page.route("**/api/voice/speech", (route) => route.fulfill({ status: 200, contentType: "audio/wav", body: buildToneBurstWav() }), ); await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent())); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 }); await sendLearnerTurn(page); const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']"); await expect(v3).toHaveAttribute("data-speech-source", "audio", { timeout: 15_000 }); const visemes = await collectDistinctVisemes(v3, 2); expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2); await expect(v3).not.toHaveAttribute("data-speech-source", "audio", { timeout: 10_000 }); await expect(v3).toHaveAttribute("data-viseme", "X", { timeout: 10_000 }); }); test("TTS 실패(500)면 텍스트 타이밍 발화로 떨어진다", async ({ page }) => { await routeSessionScreen(page); await page.route("**/api/voice/speech", (route) => route.fulfill(jsonRoute({ detail: "tts failure fixture" }, 500))); await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent())); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 }); await sendLearnerTurn(page); const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']"); await expect(v3).toHaveAttribute("data-speech-source", "text", { timeout: 15_000 }); const visemes = await collectDistinctVisemes(v3, 2); expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2); }); });