import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { parseVoicePracticeContext } from "../src/lib/voicePracticeContext"; import { expectNoHorizontalOverflow } from "./support"; const sessionId = "33333333-3333-4333-8333-333333333333"; const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const clientReply = "괜찮아요. 천천히 말해볼게요."; declare global { interface Window { __voiceErrorFixture?: { releaseError: () => void; sent: string[]; }; __voiceCrisisFixture?: { sent: string[]; }; __voiceLifecycleFixture?: { connections: number; sent: string[]; releaseFinal: () => void; dropBeforeReply: () => void; releaseSavedReplyDegraded: () => void; releaseEmptyFinal: () => void; }; } } function deferred() { let resolve!: () => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } interface RouteMvpOptions { endStatus?: number; endBody?: unknown; crisisStream?: boolean; liveCoachStatus?: "ready" | "degraded"; liveCoachHistoryStatus?: number; liveCoachHistorySource?: "database" | "runtime"; liveCoachPersistenceSource?: "database" | "runtime"; liveCoachHistoryQuotas?: Array<{ remaining: number; max: number }>; liveCoachSuggestionQuota?: { remaining: number; max: number }; alliancePreLocked?: boolean; } async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) { const sessionStartRequests: unknown[] = []; const liveCoachRequests: unknown[] = []; const alliancePulseRequests: Array<{ checkpoint?: string }> = []; const alliancePulseItems: Array> = []; let liveCoachHistoryRequests = 0; let deliveredCoachSuggestion: Record | null = null; const streamSeen = deferred(); const streamGate = deferred(); if (options.alliancePreLocked !== false) { alliancePulseItems.push({ pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 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/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "00000000-0000-0000-0000-00000000e2e1", email: "mvp.learner@hs.ac.kr", display_name: "MVP Learner", 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: "MVP Learner", self_introduction: "MVP 회기 흐름 검증용 학습자입니다.", avatar_url: "", }), }); }); await page.route("**/api/users/me/prepost-measures**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ 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", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ available: true, reason: null }), }); }); // 텍스트 턴 TTS는 이 fixture의 검증 대상이 아니다. 실제 8000 포트로 새지 않게 // 명시적으로 실패시키고, 음성 실패가 작성 중인 초안을 지우지 않는지만 본다. await page.route("**/api/voice/speech", async (route) => { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }), }); }); await page.route("**/api/personas", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([ { 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/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } sessionStartRequests.push(route.request().postDataJSON()); await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, case_id: "mvp-case-001", session_no: 1, stage: "라포", effective_openness: 0.21, recall_summary: null, degraded: false, }), }); }); await page.route(`**/api/sessions/${sessionId}`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, case_id: "mvp-case-001", persona_code: "P1", persona_name: "민서", session_no: 1, status: "active", stage: "라포", theory_mode: "cbt", effective_openness: 0.21, started_at: new Date().toISOString(), ended_at: null, review_ready: false, turns: [], }), }); }); // 이 파일은 코칭/위기/종료 회귀를 검증한다. 새 회기 전 펄스 자체는 // alliance-checkpoint 전용 시나리오에서 다루고, 여기서는 이미 잠긴 원장을 제공한다. await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { const body = route.request().postDataJSON() as { checkpoint?: "pre" | "mid" | "post"; scores?: Record; }; alliancePulseRequests.push(body); const pulseId = body.checkpoint === "mid" ? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" : "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; alliancePulseItems.push({ pulse_id: pulseId, checkpoint: body.checkpoint, status: "awaiting_agents", learner_locked_at: new Date().toISOString(), revealed_at: null, error_code: null, self_scores: body.scores, measurements: [], }); await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify({ pulse_id: pulseId, status: "awaiting_agents" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: alliancePulseItems, }), }); }); await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => { streamSeen.resolve(); await streamGate.promise; if (options.crisisStream) { const crisisResource = { title: "자살예방상담전화 109", number: "109", message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.", }; await route.fulfill({ status: 200, contentType: "text/event-stream", body: [ "event: safety", `data: ${JSON.stringify({ crisis_resource: crisisResource, conversation_stopped: true, })}`, "", "event: done", `data: ${JSON.stringify({ session_id: sessionId, stage: "라포", effective_openness: 0.21, turn_seq: 1, safety_flagged: true, crisis_resource: crisisResource, conversation_stopped: true, })}`, "", ].join("\n"), }); return; } await route.fulfill({ status: 200, contentType: "text/event-stream", body: [ "event: token", `data: ${clientReply}`, "", "event: done", `data: ${JSON.stringify({ session_id: sessionId, stage: "탐색", effective_openness: 0.42, turn_seq: 1, safety_flagged: false, })}`, "", ].join("\n"), }); }); await page.route(`**/api/sessions/${sessionId}/end`, async (route) => { const status = options.endStatus ?? 200; await route.fulfill({ status, contentType: "application/json", body: JSON.stringify( options.endBody ?? { session_id: sessionId, session_no: 1, digest_pending: true, end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 }, }, ), }); }); await page.route(`**/api/sessions/${sessionId}/live-coach`, async (route) => { const request = route.request(); if (request.method() === "GET") { const quota = options.liveCoachHistoryQuotas?.[ Math.min(liveCoachHistoryRequests, options.liveCoachHistoryQuotas.length - 1) ] ?? { remaining: 3, max: 3 }; liveCoachHistoryRequests += 1; if (options.liveCoachHistoryStatus && options.liveCoachHistoryStatus >= 400) { await route.fulfill({ status: options.liveCoachHistoryStatus, contentType: "application/json", body: JSON.stringify({ detail: "live coach history unavailable" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: options.liveCoachHistorySource ?? "database", quota, credit_events: [], events: deliveredCoachSuggestion ? [ { event_id: "fixture-coach-1", session_id: sessionId, turn_seq: 1, stage: "탐색", created_at: "2026-06-26T00:00:03.000Z", learner_text_excerpt: learnerText, client_reply_excerpt: clientReply, suggestion: deliveredCoachSuggestion, }, ] : [], }), }); return; } liveCoachRequests.push(request.postDataJSON()); deliveredCoachSuggestion = { status: options.liveCoachStatus ?? "ready", tone: "pos", focus: "reflection", title: "감정 반영이 선명합니다", message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.", next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?", rationale: options.liveCoachStatus === "degraded" ? "AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 판단입니다." : "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.", sources: [ { source_id: "live_coaching_workbook_0615", title: "0615 사례개념화 워크북", locator: "reflection", kb_kind: "source_pack", version: "2026-06-15", citation: "허가된 요약 근거", }, ], safety_note: null, latency_ms: options.liveCoachStatus === "degraded" ? 0 : 12, persistence_source: options.liveCoachPersistenceSource ?? "database", quota: options.liveCoachSuggestionQuota ?? { remaining: 2, max: 3 }, credit_events: [], }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(deliveredCoachSuggestion), }); }); await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, client: { name: "민서", initial: "민", persona: "P1 · hard", }, date: "2026-06-26", durationLabel: "1분 02초", durationSeconds: 62, reachedPhase: "탐색", sessionSignal: "종료됨", supervisorState: "평가 완료", supervisorName: "AI", summary: "평가 AI가 저장된 축어록을 분석했습니다.", phases: [{ key: "explore", label: "탐색", weight: 1 }], phaseAxis: ["0:00", "1:02"], valenceAxis: ["0:00", "1:02"], clientValence: [], counselorBaseline: [], turns: [ { id: "t1", ts: "0:01", speaker: "learner", who: "학습자", text: learnerText, techniques: [], note: null, }, { id: "t2", ts: "0:04", speaker: "client", who: "민서", text: clientReply, techniques: [], note: null, }, ], rubric: [], goodMoments: [{ title: "반영", body: "학습자가 정서를 먼저 반영했습니다." }], growthPoints: [{ title: "탐색 확장", body: "다음 턴에서 구체 상황을 더 묻습니다." }], nextLine: "그 말을 꺼내는 것도 쉽지 않았을 것 같아요.", clientFeedback: clientReply, audioUrl: null, pdfExportUrl: null, degraded: false, reviewReady: true, }), }); }); return { sessionStartRequests, liveCoachRequests, alliancePulseRequests, streamGate, streamSeen, get liveCoachHistoryRequests() { return liveCoachHistoryRequests; }, }; } async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) { await page.addInitScript((text) => { const fixture = { sent: [] as string[], socket: null as FixtureWebSocket | null, releaseError() { const socket = fixture.socket; if (!socket || socket.readyState >= FixtureWebSocket.CLOSING) return; socket.emitJson({ type: "error", code: "turn_persistence_unavailable", detail: "voice turn persistence unavailable; retry the utterance", }); socket.close(1000); }, }; window.__voiceErrorFixture = fixture; const fakeTrack = { kind: "audio", readyState: "live", stop() { this.readyState = "ended"; }, }; const fakeStream = { active: true, getTracks: () => [fakeTrack], getAudioTracks: () => [fakeTrack], }; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => fakeStream }, }); Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined }); class FakeMediaRecorder extends EventTarget { static isTypeSupported() { return true; } state = "inactive"; mimeType = "audio/webm"; ondataavailable: ((event: Event & { data: Blob }) => void) | null = null; onstop: ((event: Event) => void) | null = null; constructor(_stream: unknown, options?: { mimeType?: string }) { super(); this.mimeType = options?.mimeType ?? "audio/webm"; } start() { this.state = "recording"; } stop() { if (this.state === "inactive") return; this.state = "inactive"; const event = new Event("stop"); this.onstop?.(event); this.dispatchEvent(event); } } Object.defineProperty(window, "MediaRecorder", { configurable: true, value: FakeMediaRecorder, }); class FixtureWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; readyState = FixtureWebSocket.CONNECTING; binaryType: BinaryType = "blob"; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; constructor(_url: string | URL) { fixture.socket = this; window.setTimeout(() => { if (this.readyState !== FixtureWebSocket.CONNECTING) return; this.readyState = FixtureWebSocket.OPEN; this.onopen?.(new Event("open")); this.emitJson({ type: "ready", state: "idle" }); }, 0); } send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { if (typeof data !== "string") return; fixture.sent.push(data); let payload: { type?: string } = {}; try { payload = JSON.parse(data) as { type?: string }; } catch { return; } if (payload.type === "audio_end") { window.setTimeout(() => { this.emitJson({ type: "state", state: "thinking" }); this.emitJson({ type: "transcript", text, final: true }); }, 0); } } close(code = 1000) { if (this.readyState === FixtureWebSocket.CLOSED) return; this.readyState = FixtureWebSocket.CLOSED; const event = new Event("close") as CloseEvent; Object.defineProperty(event, "code", { value: code }); this.onclose?.(event); } emitJson(payload: unknown) { if (this.readyState === FixtureWebSocket.CLOSED) return; const event = new MessageEvent("message", { data: JSON.stringify(payload) }); this.onmessage?.(event); } } Object.defineProperty(window, "WebSocket", { configurable: true, value: FixtureWebSocket as unknown as typeof WebSocket, }); }, transcriptText); } async function installVoiceCrisisFixture(page: Page, transcriptText: string) { await page.addInitScript((text) => { const crisisResource = { title: "자살예방상담전화 109", number: "109", message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.", }; const fixture = { sent: [] as string[], socket: null as FixtureWebSocket | null, }; window.__voiceCrisisFixture = fixture; const fakeTrack = { kind: "audio", readyState: "live", stop() { this.readyState = "ended"; }, }; const fakeStream = { active: true, getTracks: () => [fakeTrack], getAudioTracks: () => [fakeTrack], }; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => fakeStream }, }); Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined }); class FakeMediaRecorder extends EventTarget { static isTypeSupported() { return true; } state = "inactive"; mimeType = "audio/webm"; ondataavailable: ((event: Event & { data: Blob }) => void) | null = null; onstop: ((event: Event) => void) | null = null; constructor(_stream: unknown, options?: { mimeType?: string }) { super(); this.mimeType = options?.mimeType ?? "audio/webm"; } start() { this.state = "recording"; } stop() { if (this.state === "inactive") return; this.state = "inactive"; const event = new Event("stop"); this.onstop?.(event); this.dispatchEvent(event); } } Object.defineProperty(window, "MediaRecorder", { configurable: true, value: FakeMediaRecorder, }); class FixtureWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; readyState = FixtureWebSocket.CONNECTING; binaryType: BinaryType = "blob"; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; constructor(_url: string | URL) { fixture.socket = this; window.setTimeout(() => { if (this.readyState !== FixtureWebSocket.CONNECTING) return; this.readyState = FixtureWebSocket.OPEN; this.onopen?.(new Event("open")); this.emitJson({ type: "ready", state: "idle" }); }, 0); } send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { if (typeof data !== "string") return; fixture.sent.push(data); let payload: { type?: string } = {}; try { payload = JSON.parse(data) as { type?: string }; } catch { return; } if (payload.type === "audio_end") { window.setTimeout(() => { this.emitJson({ type: "state", state: "thinking" }); this.emitJson({ type: "transcript", text, final: true }); this.emitJson({ type: "reply", text: "", safety_flagged: true, crisis_resource: crisisResource, conversation_stopped: true, turn_seq: 1, }); this.emitJson({ type: "state", state: "idle" }); }, 0); } } close(code = 1000) { if (this.readyState === FixtureWebSocket.CLOSED) return; this.readyState = FixtureWebSocket.CLOSED; const event = new Event("close") as CloseEvent; Object.defineProperty(event, "code", { value: code }); this.onclose?.(event); } emitJson(payload: unknown) { if (this.readyState === FixtureWebSocket.CLOSED) return; const event = new MessageEvent("message", { data: JSON.stringify(payload) }); this.onmessage?.(event); } } Object.defineProperty(window, "WebSocket", { configurable: true, value: FixtureWebSocket as unknown as typeof WebSocket, }); }, transcriptText); } async function installVoiceLifecycleFixture(page: Page) { await page.addInitScript(() => { const firstInterim = "요즘 잠을"; const firstFinal = "요즘 잠을 잘 못 자요."; const secondInterim = "오늘은 조금"; const secondFinal = "오늘은 조금 더 천천히 말해볼게요."; const savedReply = "그렇게 말해주시니 조금 안심돼요."; const sockets: FixtureWebSocket[] = []; const fixture = { connections: 0, sent: [] as string[], releaseFinal() { const socket = sockets[0]; if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return; socket.emitJson({ type: "state", state: "thinking" }); socket.emitJson({ type: "transcript", text: firstFinal, final: true }); }, dropBeforeReply() { const socket = sockets[0]; if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return; socket.close(1011); }, releaseSavedReplyDegraded() { const socket = sockets[1]; if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return; socket.emitJson({ type: "state", state: "thinking" }); socket.emitJson({ type: "transcript", text: secondFinal, final: true }); socket.emitJson({ type: "reply", text: savedReply, turn_seq: 2, stage: "탐색", effective_openness: 0.52, }); socket.emitJson({ type: "state", state: "speaking" }); socket.emitJson({ type: "degraded", reason: "TTS failed: provider disconnected" }); }, releaseEmptyFinal() { const socket = sockets[2]; if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return; socket.emitJson({ type: "state", state: "thinking" }); socket.emitJson({ type: "transcript", text: "", final: true }); socket.emitJson({ type: "state", state: "idle" }); }, }; window.__voiceLifecycleFixture = fixture; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => { const fakeTrack = { kind: "audio", readyState: "live", stop() { this.readyState = "ended"; }, }; return { active: true, getTracks: () => [fakeTrack], getAudioTracks: () => [fakeTrack], }; }, }, }); Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined }); class FakeMediaRecorder extends EventTarget { static isTypeSupported() { return true; } state = "inactive"; mimeType = "audio/webm"; ondataavailable: ((event: Event & { data: Blob }) => void) | null = null; onstop: ((event: Event) => void) | null = null; constructor(_stream: unknown, options?: { mimeType?: string }) { super(); this.mimeType = options?.mimeType ?? "audio/webm"; } start() { this.state = "recording"; } stop() { if (this.state === "inactive") return; this.state = "inactive"; const event = new Event("stop"); this.onstop?.(event); this.dispatchEvent(event); } } Object.defineProperty(window, "MediaRecorder", { configurable: true, value: FakeMediaRecorder, }); class FixtureWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; readonly connectionNumber: number; readonly isVoiceSocket: boolean; readyState = FixtureWebSocket.CONNECTING; binaryType: BinaryType = "blob"; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; constructor(url: string | URL) { this.isVoiceSocket = String(url).includes("/voice/ws"); if (!this.isVoiceSocket) { this.connectionNumber = 0; return; } fixture.connections += 1; this.connectionNumber = fixture.connections; sockets.push(this); window.setTimeout(() => { if (this.readyState !== FixtureWebSocket.CONNECTING) return; this.readyState = FixtureWebSocket.OPEN; this.onopen?.(new Event("open")); this.emitJson({ type: "ready", state: "idle" }); }, 0); } send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { if (!this.isVoiceSocket) return; if (typeof data !== "string") return; fixture.sent.push(data); let payload: { type?: string } = {}; try { payload = JSON.parse(data) as { type?: string }; } catch { return; } if (payload.type !== "audio_end") return; window.setTimeout(() => { if (this.connectionNumber === 1) { this.emitJson({ type: "transcript", text: firstInterim, final: false }); } else if (this.connectionNumber === 2) { this.emitJson({ type: "transcript", text: secondInterim, final: false }); } this.emitJson({ type: "eot", ready: false, reason: "insufficient_silence", silence_ms: 240, threshold_ms: 700, }); this.emitJson({ type: "state", state: "listening" }); }, 0); } close(code = 1000) { if (this.readyState === FixtureWebSocket.CLOSED) return; this.readyState = FixtureWebSocket.CLOSED; const event = new Event("close") as CloseEvent; Object.defineProperty(event, "code", { value: code }); this.onclose?.(event); } emitJson(payload: unknown) { if (this.readyState === FixtureWebSocket.CLOSED) return; this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(payload) })); } } Object.defineProperty(window, "WebSocket", { configurable: true, value: FixtureWebSocket as unknown as typeof WebSocket, }); }); } async function selectAllianceScore( page: Page, axis: "목표" | "과업" | "유대", scoreName: "3 보통이다" | "4 대체로 그렇다", ) { const group = page.getByRole("group", { name: new RegExp(`^${axis}`) }); await group.getByRole("radio", { name: scoreName }).check(); } test.describe("P1 MVP core loop", () => { test("discloses the synthetic client voice before and during use on desktop and mobile", async ({ page, }, testInfo) => { const disclosure = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다."; await page.setViewportSize({ width: 1440, height: 900 }); await routeMvpApi(page); await page.goto("/learn/session/P1"); const prestartDisclosure = page.locator(".sx-prestart__voice-disclosure"); await expect(prestartDisclosure).toBeVisible(); await expect(prestartDisclosure).toHaveText(disclosure); await expect(prestartDisclosure).toHaveAttribute("role", "note"); await expectNoHorizontalOverflow(page); await page.setViewportSize({ width: 390, height: 844 }); await expect(prestartDisclosure).toBeVisible(); await expectNoHorizontalOverflow(page); await prestartDisclosure.scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath("ai-voice-disclosure-prestart-mobile.png"), animations: "disabled", }); await page.getByRole("button", { name: "회기 시작" }).click(); const mobileDisclosure = page.locator(".sx-controlbar__voice-disclosure"); await expect(mobileDisclosure).toBeVisible(); await expect(mobileDisclosure).toHaveText(disclosure); await expect(page.locator(".sx-mic-block__disclosure")).toBeHidden(); await expectNoHorizontalOverflow(page); await mobileDisclosure.scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath("ai-voice-disclosure-active-mobile.png"), animations: "disabled", }); await page.setViewportSize({ width: 1440, height: 900 }); const desktopDisclosure = page.locator(".sx-mic-block__disclosure"); await expect(desktopDisclosure).toBeVisible(); await expect(desktopDisclosure).toHaveText(disclosure); await expect(mobileDisclosure).toBeHidden(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath("ai-voice-disclosure-active-desktop.png"), animations: "disabled", fullPage: true, }); }); test("preserves a transfer prescription through session creation and review", async ({ page, }, testInfo) => { await routeMvpApi(page); const query = new URLSearchParams({ launch: "transfer", prescription: "transfer-prescription-01", suite: "transfer-suite-01", trial: "transfer-trial-01", source_session: "source-session-01", criterion: "competency.empathic_reflection", novelty: "unseen_transfer", mode: "counterevidence_forecast", }); await page.goto(`/learn/session/P1?${query}`); await expect( page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }), ).toBeVisible(); await expect(page.locator(".sx-practice-launch-context")).toContainText( "처음 보는 장면", ); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}\\?`)); const persisted = new URL(page.url()); expect(persisted.searchParams.get("launch")).toBe("transfer"); expect(persisted.searchParams.get("prescription")).toBe( "transfer-prescription-01", ); expect(persisted.searchParams.get("suite")).toBe("transfer-suite-01"); expect(persisted.searchParams.get("trial")).toBe("transfer-trial-01"); expect(persisted.searchParams.get("source_session")).toBe( "source-session-01", ); expect(persisted.searchParams.get("novelty")).toBe("unseen_transfer"); expect(persisted.searchParams.get("mode")).toBe( "counterevidence_forecast", ); await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review\\?`)); const reviewUrl = new URL(page.url()); expect(reviewUrl.searchParams.get("launch")).toBe("transfer"); expect(reviewUrl.searchParams.get("prescription")).toBe( "transfer-prescription-01", ); expect(reviewUrl.searchParams.get("suite")).toBe("transfer-suite-01"); expect(reviewUrl.searchParams.get("trial")).toBe("transfer-trial-01"); await expect( page.getByRole("heading", { name: "반대근거 예측 수행 회기의 리뷰입니다.", }), ).toBeVisible(); const retry = page.getByRole("button", { name: "같은 전이 과제로 다시 연습", }); await expect(retry).toBeVisible(); expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath("practice-return-review-desktop.png"), animations: "disabled", fullPage: true, }); await retry.focus(); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/learn\/practice\?/); const retryUrl = new URL(page.url()); expect(retryUrl.searchParams.get("launch")).toBe("transfer"); expect(retryUrl.searchParams.get("trial")).toBe("transfer-trial-01"); }); test("keeps voice provenance legible on mobile and blocks malformed handoffs", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await routeMvpApi(page); await page.route("**/api/sessions/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({}), }), ); await page.route("**/api/sessions", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ sessions: [] }), }); }); const voiceQuery = new URLSearchParams({ mode: "voice", source_session: "source-session-01", source_scene: "oas-g7-event-silence-1", scene_type: "silence", scene_start_ms: "18000", scene_end_ms: "26400", }); await page.goto(`/learn/session/${sessionId}/review?${voiceQuery}`); await expect( page.getByRole("heading", { name: "침묵 뒤 응답 수행 회기의 리뷰입니다.", }), ).toBeVisible(); const retry = page.getByRole("button", { name: "같은 장면을 다시 연습" }); await expect(retry).toBeVisible(); expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath("voice-practice-return-review-mobile.png"), animations: "disabled", fullPage: true, }); await retry.focus(); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/learn\/practice\?/); expect(parseVoicePracticeContext(new URL(page.url()).searchParams)).toEqual({ mode: "voice", sourceSessionId: "source-session-01", sourceSceneId: "oas-g7-event-silence-1", sceneType: "silence", sceneStartMs: 18000, sceneEndMs: 26400, }); const malformedQuery = new URLSearchParams({ mode: "voice", source_session: "source-session-01", source_scene: "oas-g7-event-silence-1", scene_type: "silence", scene_start_ms: "18000", }); expect(parseVoicePracticeContext(malformedQuery)).toBeNull(); await page.goto(`/learn/practice?${malformedQuery}`); await expect( page.getByRole("heading", { name: "원본 회기 정보를 확인할 수 없습니다." }), ).toBeVisible(); await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeDisabled(); await expectNoHorizontalOverflow(page); await page.goto(`/learn/session/P1?${malformedQuery}`); await expect( page.getByRole("heading", { name: "음성 재연습의 출처를 다시 확인해 주세요." }), ).toBeVisible(); await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled(); await expectNoHorizontalOverflow(page); await page.goto(`/learn/session/${sessionId}/review?${malformedQuery}`); await expect( page.getByRole("heading", { name: "이 리뷰의 연습 출처를 검증할 수 없습니다.", }), ).toBeVisible(); const recover = page.getByRole("button", { name: "피드백에서 다시 선택" }); await recover.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("tab", { name: "피드백" })).toHaveAttribute( "aria-selected", "true", ); await expectNoHorizontalOverflow(page); }); test("locks pre before the first turn and offers a persistent mid-session pulse", async ({ page, }, testInfo) => { const api = await routeMvpApi(page, { alliancePreLocked: false }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" })).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); await page.screenshot({ path: path.resolve( process.cwd(), "../../docs/ops/evidence", `g1-alliance-pre-${testInfo.project.name}-2026-08-07.png`, ), fullPage: true, }); await selectAllianceScore(page, "목표", "3 보통이다"); await selectAllianceScore(page, "과업", "3 보통이다"); await selectAllianceScore(page, "유대", "3 보통이다"); await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click(); await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual(["pre"]); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await page.getByRole("button", { name: "30초 점검" }).click(); await page.screenshot({ path: path.resolve( process.cwd(), "../../docs/ops/evidence", `g1-alliance-mid-${testInfo.project.name}-2026-08-07.png`, ), fullPage: true, }); await selectAllianceScore(page, "목표", "4 대체로 그렇다"); await selectAllianceScore(page, "과업", "4 대체로 그렇다"); await selectAllianceScore(page, "유대", "4 대체로 그렇다"); await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click(); await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual([ "pre", "mid", ]); await expect(page.getByText("회기 전·중 판단이 원장에 잠겼습니다.")).toBeVisible(); }); test("runs login, P1 text stream, session end, and review feedback @single-run", async ({ page, }) => { const diagnostics: string[] = []; page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`)); page.on("console", (message) => { if (message.type() === "error") diagnostics.push(`console: ${message.text()}`); }); const api = await routeMvpApi(page); await page.goto("/learn/session/P1"); await expect( page.getByRole("button", { name: "회기 시작" }), diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")), ).toBeVisible(); await page.getByRole("button", { name: /CBT/ }).click(); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); expect(api.sessionStartRequests[0]).toMatchObject({ persona_code: "P1", theory_mode: "cbt", }); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible(); const composer = page.getByLabel("학습자 발화 입력"); await expect(composer).toBeEnabled(); await expect(page.getByRole("button", { name: "보내기" })).toBeDisabled(); await composer.fill("다음 질문을 미리 작성합니다."); api.streamGate.resolve(); await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible(); await expect(composer).toHaveValue("다음 질문을 미리 작성합니다."); await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled(); await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`)); await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible(); // ≤1180 폭에서는 리뷰가 가로 탭 — 마지막 내담자 반응은 피드백 탭에 있다. const feedbackTab = page.locator(".sr-tabs button", { hasText: "피드백" }); if (await feedbackTab.isVisible().catch(() => false)) { await feedbackTab.click(); } await expect(page.getByText("마지막 내담자 반응")).toBeVisible(); await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible(); }); test("shows AI tutor coaching in coached mode after a completed turn", async ({ page }) => { const api = await routeMvpApi(page); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible(); await expect( page.getByText("학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다."), ).toBeVisible(); await page.locator(".sx-coach-card").getByRole("button", { name: "근거 보기" }).click(); const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']"); await expect(evidenceDialog).toBeVisible(); await expect(evidenceDialog).toContainText("0615 사례개념화 워크북"); await expect(evidenceDialog).toContainText("reflection · source_pack · 2026-06-15"); await expect(evidenceDialog).toContainText("허가된 요약 근거"); await evidenceDialog.getByRole("button", { name: "닫기" }).click(); await expect(evidenceDialog).toHaveCount(0); expect(api.liveCoachRequests).toHaveLength(1); expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText, client_reply: clientReply, turn_seq: 1, }); }); test("refreshes stale empty AI tutor quota before blocking coaching", async ({ page }) => { const api = await routeMvpApi(page, { liveCoachHistoryQuotas: [ { remaining: 0, max: 3 }, { remaining: 1, max: 3 }, { remaining: 0, max: 3 }, ], liveCoachSuggestionQuota: { remaining: 0, max: 3 }, }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible(); expect(api.liveCoachRequests).toHaveLength(1); await expect.poll(() => api.liveCoachHistoryRequests).toBeGreaterThanOrEqual(2); }); test("shows refreshed AI tutor quota exhaustion over a stale coaching card", async ({ page, }) => { const api = await routeMvpApi(page, { liveCoachHistoryQuotas: [ { remaining: 0, max: 3 }, { remaining: 0, max: 3 }, ], liveCoachSuggestionQuota: { remaining: 0, max: 3 }, }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); const coachCard = page.locator(".sx-coach-card"); await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible(); expect(api.liveCoachRequests).toHaveLength(1); await page.getByLabel("학습자 발화 입력").fill("그때 마음을 더 자세히 말해줘도 괜찮아요."); await page.getByRole("button", { name: "보내기" }).click(); await expect(coachCard.getByText("코칭 기회를 모두 사용했습니다.")).toBeVisible(); await expect(coachCard.getByText("감정 반영이 선명합니다")).toHaveCount(0); expect(api.liveCoachRequests).toHaveLength(1); }); test("surfaces AI tutor history load failure instead of an empty history", async ({ page }) => { const api = await routeMvpApi(page, { liveCoachHistoryStatus: 503 }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); const coachCard = page.locator(".sx-coach-card"); await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible(); await coachCard.getByRole("button", { name: "이력 보기" }).click(); const historyDialog = page.locator(".sx-coach-history [role='dialog']"); await expect(historyDialog).toBeVisible(); await expect(historyDialog.getByRole("alert")).toContainText("코칭 이력을 불러오지 못했습니다."); await expect(historyDialog).not.toContainText("아직 이 턴에 저장된 코칭 이력이 없습니다."); expect(api.liveCoachRequests).toHaveLength(1); }); test("labels runtime AI tutor persistence as temporary history", async ({ page }) => { const api = await routeMvpApi(page, { liveCoachHistorySource: "runtime", liveCoachPersistenceSource: "runtime", }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); const coachCard = page.locator(".sx-coach-card"); await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible(); await expect(coachCard).toContainText("임시 이력"); await expect(coachCard).toContainText("DB에 확정 저장되지 않아"); await coachCard.getByRole("button", { name: "이력 보기" }).click(); const historyDialog = page.locator(".sx-coach-history [role='dialog']"); await expect(historyDialog).toContainText("임시 저장소 기준"); expect(api.liveCoachRequests).toHaveLength(1); }); test("marks pending voice transcript as failed when turn persistence fails @single-run", async ({ page, }) => { const failedVoiceText = "요즘 잠을 잘 못 자요."; await routeMvpApi(page); await installVoicePersistenceFailureFixture(page, failedVoiceText); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); const micButton = page.getByRole("button", { name: "마이크 켜기" }); await expect(micButton).toBeEnabled(); await micButton.click(); await page.getByRole("button", { name: "발화 보내기" }).click(); const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText }); await expect(learnerBubble).toBeVisible(); await expect(learnerBubble).toHaveClass(/is-partial/); await expect( page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." }), ).toBeVisible(); await page.evaluate(() => window.__voiceErrorFixture?.releaseError()); await expect(learnerBubble).toBeVisible(); await expect(learnerBubble).toHaveClass(/is-failed/); await expect(learnerBubble).not.toHaveClass(/is-partial/); await expect(learnerBubble.getByText("저장 실패 · 다시 시도하세요.")).toBeVisible(); await expect(page.locator(".sx-utt.is-thinking")).toHaveCount(0); await expect(page.getByRole("alert")).toContainText("음성 발화를 저장하지 못했습니다."); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await expect(page.locator(".sx-mic-block__l")).toContainText("마이크 오류"); }); test("keeps provider transcript lifecycle visible and offers keyboard-safe voice recovery @single-run", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" }); await routeMvpApi(page); await installVoiceLifecycleFixture(page); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); const pauseButton = page.getByRole("button", { name: "일시정지" }); await pauseButton.focus(); await pauseButton.press("Space"); await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible(); expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(0); await page.getByRole("button", { name: "이어가기" }).click(); await page.getByRole("button", { name: "마이크 켜기" }).click(); await page.getByRole("button", { name: "발화 보내기" }).click(); const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" }); await expect(transcriptLog).toHaveAttribute("aria-live", "polite"); const firstLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({ hasText: "요즘 잠을", }); await expect(firstLearnerBubble).toHaveCount(1); await expect(firstLearnerBubble).toHaveClass(/is-partial/); await expect(firstLearnerBubble.getByText("실시간 전사")).toBeVisible(); await expect(page.locator(".sx-mic-block__h")).toContainText("발화 종료를 확인하지 못했습니다"); await page.evaluate(() => window.__voiceLifecycleFixture?.releaseFinal()); await expect(firstLearnerBubble).toContainText("요즘 잠을 잘 못 자요."); await expect(firstLearnerBubble.getByText("전사 확정, 응답 연결 중")).toBeVisible(); await expect(transcriptLog.locator(".sx-utt.is-thinking")).toContainText("답변을 준비 중입니다."); await page.evaluate(() => window.__voiceLifecycleFixture?.dropBeforeReply()); await expect(firstLearnerBubble).toHaveClass(/is-failed/); await expect(firstLearnerBubble).not.toHaveClass(/is-partial/); await expect(page.getByRole("alert")).toContainText("음성 연결이 종료되어 발화를 저장하지 못했습니다"); const retryButton = page.getByRole("button", { name: "음성 다시 연결" }); await expect(retryButton).toBeVisible(); const retryBox = await retryButton.boundingBox(); expect(retryBox?.height ?? 0).toBeGreaterThanOrEqual(44); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath("voice-recovery-before-reply-mobile-light.png"), fullPage: true, }); await retryButton.focus(); await retryButton.press("Space"); await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible(); expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(2); await page.getByRole("button", { name: "발화 보내기" }).click(); const secondLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({ hasText: "오늘은 조금", }); await expect(secondLearnerBubble).toHaveCount(1); await expect(secondLearnerBubble.getByText("실시간 전사")).toBeVisible(); await page.evaluate(() => window.__voiceLifecycleFixture?.releaseSavedReplyDegraded()); await expect(secondLearnerBubble).toContainText("오늘은 조금 더 천천히 말해볼게요."); await expect(secondLearnerBubble).not.toHaveClass(/is-partial/); await expect(secondLearnerBubble).not.toHaveClass(/is-failed/); await expect(transcriptLog.getByText("그렇게 말해주시니 조금 안심돼요.")).toBeVisible(); await expect(page.getByRole("alert")).toHaveCount(0); await expect(page.getByRole("button", { name: "음성 다시 연결" })).toBeVisible(); await expect(page.locator(".sx-mic-block__h")).toContainText("내담자 응답은 저장됐지만"); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await page.evaluate(() => { document.documentElement.setAttribute("data-theme", "dark"); localStorage.setItem("vignette.theme", "dark"); }); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath("voice-recovery-after-reply-mobile-dark.png"), fullPage: true, }); await page.getByRole("button", { name: "음성 다시 연결" }).click(); await page.getByRole("button", { name: "발화 보내기" }).click(); await page.evaluate(() => window.__voiceLifecycleFixture?.releaseEmptyFinal()); await expect(transcriptLog.locator(".sx-utt.is-learner")).toHaveCount(2); await expect(page.locator(".sx-mic-block__h")).toContainText("음성을 인식하지 못했습니다"); await expect(transcriptLog.locator(".sx-utt.is-thinking")).toHaveCount(0); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); }); test("keeps crisis safety gate visible for a voice conversation stop", async ({ page }) => { const crisisText = "죽고 싶다는 생각이 자꾸 들어요."; const api = await routeMvpApi(page); await installVoiceCrisisFixture(page, crisisText); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); const micButton = page.getByRole("button", { name: "마이크 켜기" }); await expect(micButton).toBeEnabled(); await micButton.click(); await page.getByRole("button", { name: "발화 보내기" }).click(); const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText }); await expect(learnerBubble).toBeVisible(); await expect(learnerBubble).not.toHaveClass(/is-partial/); await expect(learnerBubble).not.toHaveClass(/is-failed/); await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible(); await expect(page.getByRole("link", { name: "109" })).toBeVisible(); await expect(page.getByText("내담자 응답 없음")).toHaveCount(0); await expect(page.locator(".sx-utt.is-client")).toHaveCount(0); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); await expect(page.getByRole("button", { name: "마이크 켜기" })).toBeDisabled(); const sent = await page.evaluate(() => window.__voiceCrisisFixture?.sent ?? []); expect(sent.some((payload) => payload.includes("\"audio_end\""))).toBe(true); expect(sent.some((payload) => payload.includes("\"close\""))).toBe(true); expect(api.liveCoachRequests).toHaveLength(0); }); test("labels degraded AI tutor fallback as replacement coaching", async ({ page }) => { const api = await routeMvpApi(page, { liveCoachStatus: "degraded" }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); const coachCard = page.locator(".sx-coach-card"); await expect(coachCard).toContainText("대체 코칭"); await expect(coachCard).toContainText("AI 응답 대체"); await expect(coachCard).toContainText("AI 코칭 엔진 응답 대신"); expect(api.liveCoachRequests).toHaveLength(1); await coachCard.getByRole("button", { name: "근거 보기" }).click(); const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']"); await expect(evidenceDialog).toBeVisible(); await expect(evidenceDialog).toContainText("AI 코칭 엔진 응답 대신"); await expect(evidenceDialog).toContainText("0615 사례개념화 워크북"); await evidenceDialog.getByRole("button", { name: "닫기" }).click(); await expect(evidenceDialog).toHaveCount(0); const coachMark = page.locator(".sx-utt__coach-mark.is-degraded"); await expect(coachMark).toBeVisible(); await expect(coachMark).toHaveAttribute("title", /AI 응답 대체/); await coachMark.click(); const historyDialog = page.locator(".sx-coach-history [role='dialog']"); await expect(historyDialog).toContainText("AI 응답 대체"); await expect(historyDialog).toContainText("0615 사례개념화 워크북"); }); test("keeps crisis safety gate visible instead of showing empty client reply @single-run", async ({ page }) => { const api = await routeMvpApi(page, { crisisStream: true }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "코칭" }).click(); await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요."); await page.getByRole("button", { name: "보내기" }).click(); await api.streamSeen.promise; api.streamGate.resolve(); await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible(); await expect(page.getByRole("link", { name: "109" })).toBeVisible(); await expect( page.locator(".sx-signal__one-text").filter({ hasText: "위기 안전게이트 작동" }), ).toBeVisible(); await expect(page.getByText("내담자 응답 없음")).toHaveCount(0); await expect(page.locator(".sx-utt.is-client")).toHaveCount(0); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); expect(api.liveCoachRequests).toHaveLength(0); }); test("does not navigate to review when session end fails", async ({ page }) => { await routeMvpApi(page, { endStatus: 503, endBody: { detail: "session end persistence unavailable" }, }); await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}$`)); await expect(page.getByRole("alert")).toContainText("회기 종료에 실패했습니다."); await expect(page.getByRole("alert")).toContainText("session end persistence unavailable"); }); });