import { expect, test, type Page } from "@playwright/test"; const sessionId = "33333333-3333-4333-8333-333333333333"; const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const clientReply = "괜찮아요. 천천히 말해볼게요."; declare global { interface Window { __voiceErrorFixture?: { releaseError: () => void; sent: string[]; }; __voiceCrisisFixture?: { sent: string[]; }; } } 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 }; } async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) { const sessionStartRequests: unknown[] = []; const liveCoachRequests: unknown[] = []; let liveCoachHistoryRequests = 0; let deliveredCoachSuggestion: Record | null = null; const streamSeen = deferred(); const streamGate = deferred(); 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 }), }); }); 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: [], }), }); }); 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, 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); } test.describe("P1 MVP core loop", () => { 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(); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); api.streamGate.resolve(); await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).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("내담자가 남긴 것")).toBeVisible(); await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible(); await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).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 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"); }); });