vignette/apps/web/e2e/session-mvp.spec.ts
2026-07-01 12:10:52 +09:00

403 lines
14 KiB
TypeScript

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<void>((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");
});
});