vignette/apps/web/e2e/inner-reaction.spec.ts
Yun Chan 29c406d89f Jev 내담자 평가·표현 v2와 속마음 공개
상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다.

trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
2026-09-30 13:23:09 +09:00

594 lines
22 KiB
TypeScript

/* =====================================================================
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);
});
});