import { expect, test, type Page } from "@playwright/test"; const sessionId = "33333333-3333-4333-8333-333333333333"; const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const clientReply = "괜찮아요. 천천히 말해볼게요."; function deferred() { let resolve!: () => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } interface RouteMvpOptions { endStatus?: number; endBody?: unknown; crisisStream?: boolean; } async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) { const sessionStartRequests: unknown[] = []; const liveCoachRequests: unknown[] = []; 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/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") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "runtime", events: [] }), }); return; } liveCoachRequests.push(request.postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ready", tone: "pos", focus: "reflection", title: "감정 반영이 선명합니다", message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.", next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?", rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.", 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: 12, }), }); }); 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 }; } 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(); expect(api.liveCoachRequests).toHaveLength(1); expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText, client_reply: clientReply, turn_seq: 1, }); }); 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"); }); });