/* ===================================================================== inner-reaction.spec.ts — P3a "내담자 속마음" UI(§8.2·§9) 검증 스펙. full-sweep-session.spec.ts / session-review-fixture.ts / admin-affect.spec.ts의 API route mock 패턴을 그대로 따른다. 실제 AI 엔진 턴 생성·실 로그인 없이 전부 fixture로 검증한다(auth/me 도 직접 mock — admin-affect.spec.ts 패턴). ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; import type { ClientInnerReactionV1 } from "../src/lib/api"; import { filledReviewResponse } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; const fixtureSessionId = "66666666-6666-4666-8666-666666666666"; const learnerText = "그 이야기를 조금 더 들려주실 수 있을까요?"; const clientReply = "음... 그게, 괜찮아요."; const innerReactionFixture: ClientInnerReactionV1 = { schema_version: 1, turn_seq: 1, experienced: ["평가받거나 탓을 듣는 것처럼 느꼈다"], feelings: [{ intensity: "뚜렷한", label: "수치심" }], stance: { code: "pull_back", label: "한발 물러났다" }, display: { code: "covered_by_agreement", label: "속마음과 달리 겉으로는 수긍하는 말로 덮었다", }, hidden_gap: true, }; interface SessionFixtureOptions { learnerFeedbackEnabled?: boolean; innerReaction?: ClientInnerReactionV1 | null; } /** * 이 스펙이 명시적으로 mock 하지 않은 `**\/api/**` 요청을 전부 404로 막는다. * 실 백엔드가 떠 있을 때(PLAYWRIGHT_REUSE_EXISTING_SERVER=1) mock 안 한 보조 API가 * 실 서버의 401을 그대로 받으면 AUTH_EXPIRED_EVENT로 이어져 화면이 로그인으로 * 튕긴다 — admin-affect.spec.ts 패턴대로 catch-all을 가장 먼저 등록해 두면, * Playwright는 나중에 등록한 route를 먼저 매칭하므로 이후 등록하는 구체 mock들이 * 이 catch-all보다 우선한다. */ async function routeUnmockedApiCatchAll(page: Page) { await page.route("**/api/**", async (route) => { const request = route.request(); await route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: `unmocked API in inner-reaction e2e: ${request.method()} ${new URL(request.url()).pathname}`, }), }); }); } async function routeInnerReactionSessionApi(page: Page, options: SessionFixtureOptions = {}) { const learnerFeedbackEnabled = options.learnerFeedbackEnabled ?? true; const innerReaction = options.innerReaction === undefined ? innerReactionFixture : options.innerReaction; await routeUnmockedApiCatchAll(page); 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-0000000ir001", email: "inner-reaction@hs.ac.kr", display_name: "IR 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: "IR Learner", self_introduction: "속마음 UI 검증용 학습자입니다.", avatar_url: "", }), }); }); 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: "속마음 UI 검증용 상담 연습", 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; } await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ session_id: fixtureSessionId, case_id: "ir-case-001", session_no: 1, stage: "라포", effective_openness: 0.21, recall_summary: null, degraded: false, goal_stages: ["라포", "탐색"], duration_limit_seconds: 3600, warning_before_end_seconds: 600, learner_feedback_enabled: learnerFeedbackEnabled, }), }); }); await page.route(`**/api/sessions/${fixtureSessionId}`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: fixtureSessionId, case_id: "ir-case-001", persona_code: "P1", persona_name: "민서", session_no: 1, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.21, started_at: new Date().toISOString(), ended_at: null, review_ready: false, turns: [], goal_stages: ["라포", "탐색"], progress: null, duration_limit_seconds: 3600, warning_before_end_seconds: 600, learner_feedback_enabled: learnerFeedbackEnabled, }), }); }); await page.route(`**/api/sessions/${fixtureSessionId}/alliance-pulses`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [ { 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/sessions/${fixtureSessionId}/stream`, async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: [ "event: token", `data: ${clientReply}`, "", "event: done", // turn_seq 를 싣지 않아 텍스트 턴 뒤 TTS(speakClientTurn) 경로를 결정론적으로 생략한다 // (full-sweep-session.spec.ts 와 같은 관례). `data: ${JSON.stringify({ session_id: fixtureSessionId, stage: "탐색", effective_openness: 0.42, safety_flagged: false, inner_reaction: innerReaction, })}`, "", ].join("\n"), }); }); await page.route(`**/api/sessions/${fixtureSessionId}/live-coach`, async (route) => { if (route.request().method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [], }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ready", tone: "pos", focus: "reflection", title: "감정 반영이 선명합니다", message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.", next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?", rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.", sources: [], safety_note: null, latency_ms: 12, persistence_source: "database", quota: { remaining: 2, max: 3 }, credit_events: [], }), }); }); } async function startFixtureSession(page: Page) { await page.goto("/learn/session/P1"); const startButton = page.getByRole("button", { name: "회기 시작" }); await expect(startButton).toBeEnabled(); await startButton.click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); } async function sendLearnerTurn(page: Page) { await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); } const REVIEW_SESSION_ID = "00000000-0000-4000-8000-0000000009ir"; function reviewResponseWithInnerReaction(sessionId: string) { const response = filledReviewResponse(sessionId); response.turns = response.turns.map((turn) => turn.id === "t1" ? { ...turn, innerReaction: innerReactionFixture } : turn, ); return response; } async function routeInnerReactionReviewAuth(page: Page, role: "learner" | "teacher") { await routeUnmockedApiCatchAll(page); await page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: role === "teacher" ? "00000000-0000-0000-0000-0000000ir901" : "00000000-0000-0000-0000-0000000ir101", email: role === "teacher" ? "ir.teacher@hs.ac.kr" : "ir.learner@hs.ac.kr", role, display_name: role === "teacher" ? "IR Teacher" : "IR Learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: role === "teacher" ? "IR Teacher" : "IR Learner", self_introduction: "", avatar_url: "", }), }); }); } async function routeInnerReactionReview(page: Page) { await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(reviewResponseWithInnerReaction(REVIEW_SESSION_ID)), }); }); await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/alliance-pulses`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }); }); } test.describe("inner reaction — session", () => { test("shows the client inner reaction mark, expands the card, and flags the hidden gap", async ({ page, }, testInfo) => { await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); const mark = page.getByRole("button", { name: "이 발화의 내담자 속마음 보기" }); await expect(mark).toBeVisible(); await expect(mark).toHaveClass(/is-warn/); await expect(mark).toHaveAttribute("aria-expanded", "false"); await mark.click(); await expect(mark).toHaveAttribute("aria-expanded", "true"); const card = page.locator(".sx-utt__inner-card"); await expect(card).toBeVisible(); await expect(card).toContainText("내담자 속마음"); await expect(card).toContainText( "가상 내담자의 시뮬레이션 반응이며 평가 점수가 아닙니다", ); await expect(card).toContainText("겉과 속이 달랐던 순간"); await expect(card).toContainText("평가받거나 탓을 듣는 것처럼 느꼈다"); await expect(card).toContainText("뚜렷한 수치심"); await expect(card).toContainText( "속마음과 달리 겉으로는 수긍하는 말로 덮었다", ); await expect(card).toContainText("한발 물러났다"); // 숫자·확률·영문 코드는 화면에 남지 않는다. const cardText = await card.innerText(); expect(/\d/.test(cardText)).toBe(false); expect(cardText).not.toContain("pull_back"); expect(cardText).not.toContain("covered_by_agreement"); // 스크린샷은 두 project가 같은 파일 경로를 다투지 않도록 데스크톱에서만 남긴다. if (testInfo.project.name === "chromium-desktop") { // 우측 라이브 코칭 패널의 최신 속마음 카드는 패널 내부 스크롤(overflow-y: auto)로 // 열리므로, 캡처 전 마지막 줄까지 패널 스크롤을 맞춰 잘림 없이 보이게 한다. await page .locator(".sx-signal__inner-reaction .vg-ir__row", { hasText: "태도" }) .scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/inner-reaction-session-1440-expanded.png", fullPage: true, }); } }); // 라이브 코칭 우측 패널은 모바일(≤880px)에서 session.css가 의도적으로 숨긴다 // (full-sweep-session.spec.ts "session-live-signal"과 같은 기존 관례). test("shows the latest client inner reaction at the top of the live coaching panel", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.includes("mobile"), "라이브 코칭 우측 패널은 모바일에서 의도적으로 숨김", ); await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); const panelCard = page.locator(".sx-signal__inner-reaction"); await expect(panelCard).toBeVisible(); await expect(panelCard).toContainText("한발 물러났다"); await expect(panelCard).toContainText("겉과 속이 달랐던 순간"); // 패널 높이보다 카드가 길어 "태도" 줄이 첫 화면에서 잘려도 패널 내부 // 스크롤(overflow-y: auto)로 닿을 수 있어야 한다. const stanceRow = panelCard.locator(".vg-ir__row", { hasText: "태도" }); await stanceRow.scrollIntoViewIfNeeded(); await expect(stanceRow).toBeVisible(); await expect(stanceRow).toContainText("한발 물러났다"); }); test("keeps the reveal switch track wider than tall and the knob no taller than the track", async ({ page, }) => { await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); const toggle = page.getByRole("switch", { name: "속마음 보기" }); await expect(toggle).toBeVisible(); const trackBox = await page.locator(".sx-inner-toggle__track").boundingBox(); const knobBox = await page.locator(".sx-inner-toggle__knob").boundingBox(); expect(trackBox).not.toBeNull(); expect(knobBox).not.toBeNull(); expect(trackBox!.width).toBeGreaterThan(trackBox!.height); expect(knobBox!.height).toBeLessThanOrEqual(trackBox!.height); }); test("hides inner reaction display when the reveal switch is off and remembers the choice after reload", async ({ page, }, testInfo) => { const isMobile = testInfo.project.name.includes("mobile"); await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); const toggle = page.getByRole("switch", { name: "속마음 보기" }); await expect(toggle).toHaveAttribute("aria-checked", "true"); await expect(page.locator(".sx-utt__inner-mark")).toBeVisible(); if (!isMobile) { await expect(page.locator(".sx-signal__inner-reaction")).toBeVisible(); } await toggle.click(); await expect(toggle).toHaveAttribute("aria-checked", "false"); await expect(page.locator(".sx-utt__inner-mark")).toHaveCount(0); if (!isMobile) { await expect(page.locator(".sx-signal__inner-reaction")).toHaveCount(0); } await page.reload(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); const toggleAfterReload = page.getByRole("switch", { name: "속마음 보기" }); await expect(toggleAfterReload).toHaveAttribute("aria-checked", "false"); }); test("hides inner reaction in immersive feedback mode even when data exists", async ({ page, }, testInfo) => { const isMobile = testInfo.project.name.includes("mobile"); await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); await expect(page.locator(".sx-utt__inner-mark")).toBeVisible(); if (!isMobile) { await expect(page.locator(".sx-signal__inner-reaction")).toBeVisible(); } await page.getByRole("button", { name: "몰입" }).click(); await expect(page.locator(".sx-utt__inner-mark")).toHaveCount(0); if (!isMobile) { await expect(page.locator(".sx-signal__inner-reaction")).toHaveCount(0); } }); test("hides the reveal switch when learner feedback is disabled for the session", async ({ page, }) => { await routeInnerReactionSessionApi(page, { learnerFeedbackEnabled: false, innerReaction: null, }); await startFixtureSession(page); await expect(page.getByRole("switch", { name: "속마음 보기" })).toHaveCount(0); }); test("has no horizontal overflow at 390px and 320px with the inner reaction card expanded", async ({ page, }, testInfo) => { await routeInnerReactionSessionApi(page); await startFixtureSession(page); await sendLearnerTurn(page); await page.getByRole("button", { name: "이 발화의 내담자 속마음 보기" }).click(); await expect(page.locator(".sx-utt__inner-card")).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectNoHorizontalOverflow(page); if (testInfo.project.name === "chromium-desktop") { await page.screenshot({ path: "node_modules/.tmp/inner-reaction-session-390-expanded.png", fullPage: true, }); } await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectNoHorizontalOverflow(page); }); }); test.describe("inner reaction — review", () => { test("expands the client inner reaction fold on the learner review and flags the hidden gap", async ({ page, }, testInfo) => { await routeInnerReactionReviewAuth(page, "learner"); await routeInnerReactionReview(page); await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); const fold = page.locator(".sr-inner-reaction"); await expect(fold).toHaveCount(1); await expect(fold).toContainText("내담자 속마음"); await expect(fold).toContainText("겉과 속이 달랐던 순간"); await expect(fold).toContainText("한발 물러났다"); await expect(page.locator(".sr-inner-reaction__card")).toHaveCount(0); await page.locator(".sr-inner-reaction__toggle").click(); const card = page.locator(".sr-inner-reaction__card"); await expect(card).toBeVisible(); await expect(card).toContainText("평가받거나 탓을 듣는 것처럼 느꼈다"); await expect(card).toContainText("뚜렷한 수치심"); await expect(card).toContainText( "속마음과 달리 겉으로는 수긍하는 말로 덮었다", ); // 펼친 카드는 접힘 요약 줄과 같은 "내담자 속마음" 머리줄을 다시 그리지 않는다. await expect(fold.getByText("내담자 속마음", { exact: true })).toHaveCount(1); if (testInfo.project.name === "chromium-desktop") { await page.screenshot({ path: "node_modules/.tmp/inner-reaction-review-1440-expanded.png", fullPage: true, }); } }); test("does not render the fold when a turn has no inner reaction", async ({ page }) => { await routeInnerReactionReviewAuth(page, "learner"); await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(filledReviewResponse(REVIEW_SESSION_ID)), }); }); await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/alliance-pulses`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }); }); await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await expect(page.locator(".sr-inner-reaction")).toHaveCount(0); }); test("also shows the inner reaction fold on the /teach/ supervisor review route", async ({ page, }) => { await routeInnerReactionReviewAuth(page, "teacher"); await routeInnerReactionReview(page); await page.goto(`/teach/session/${REVIEW_SESSION_ID}/review`); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await expect(page.locator(".sr-inner-reaction")).toBeVisible(); await expect(page.locator(".sr-inner-reaction")).toContainText("내담자 속마음"); }); test("has no horizontal overflow at 390px and 320px with the inner reaction fold expanded", async ({ page, }, testInfo) => { await routeInnerReactionReviewAuth(page, "learner"); await routeInnerReactionReview(page); await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await page.locator(".sr-inner-reaction__toggle").click(); await expect(page.locator(".sr-inner-reaction__card")).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectNoHorizontalOverflow(page); // 리사이즈 전 스크롤 오프셋이 새 좁은 레이아웃에서는 다른 내용을 가리킬 수 있어 // 스크린샷 전에 카드를 다시 뷰포트로 스크롤한다. await page.locator(".sr-inner-reaction__card").scrollIntoViewIfNeeded(); if (testInfo.project.name === "chromium-desktop") { await page.screenshot({ path: "node_modules/.tmp/inner-reaction-review-390-expanded.png", fullPage: true, }); } await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectNoHorizontalOverflow(page); }); });