797 lines
31 KiB
TypeScript
797 lines
31 KiB
TypeScript
/* =====================================================================
|
|
uc-session-conversation.spec.ts — 회기 대화(텍스트 턴·SSE 스트림) 유스케이스 E2E
|
|
|
|
목적: 학습자가 텍스트 입력으로 내담자와 턴을 주고받는 실제 여정을
|
|
화면 계약 기준으로 검증한다. (입력→전송→즉시 표시, SSE delta 누적,
|
|
done 후 재활성화, 중복 전송 차단, 실패 시 작성문 보존, 자동 스크롤,
|
|
PII placeholder 자연어 표시)
|
|
|
|
근거: apps/web/src/pages/Session.tsx 의 composer/handleSend/스트림 소비 코드,
|
|
apps/web/src/lib/api.ts openSessionStream(SSE: token/done/safety/error/ping,
|
|
token data 는 원시 텍스트 조각), apps/web/src/lib/piiDisplay.ts 표시층 마스킹,
|
|
apps/web/src/pages/session/voiceCapture.ts sessionVoiceStatusView
|
|
(textComposerDisabled/textTurnBlocked 계약).
|
|
|
|
주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
|
|
중복 회피: session-mvp.spec.ts(스트림 중 초안 작성 보존·코칭 흐름),
|
|
session-layout.spec.ts(503 거절/수락 후 오류 시 로컬 축어록 제거),
|
|
session-persistence.spec.ts(@single-run 실 DB 영속)과 각도를 다르게 잡았다.
|
|
===================================================================== */
|
|
|
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
import { expectNoHorizontalOverflow } from "./support";
|
|
|
|
const SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
|
const LEARNER_TEXT = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
|
const REPLY_TOKENS = ["괜찮아요. ", "천천히 ", "말해볼게요."];
|
|
const REPLY_FULL = "괜찮아요. 천천히 말해볼게요.";
|
|
|
|
function jsonRoute(body: unknown, status = 200) {
|
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((next) => {
|
|
resolve = next;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
/* SSE 본문 빌더 — openSessionStream 의 파서 계약(event/data 라인, 빈 줄 dispatch)에 맞춘다.
|
|
token 이벤트의 data 는 JSON 이 아니라 원시 텍스트 조각이다(api.ts dispatch 참고). */
|
|
function sseTurnBody(tokens: string[], done: Record<string, unknown>): string {
|
|
const lines: string[] = [];
|
|
for (const token of tokens) lines.push("event: token", `data: ${token}`, "");
|
|
lines.push("event: done", `data: ${JSON.stringify(done)}`, "");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function doneEvent(turnSeq: number, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
session_id: SESSION_ID,
|
|
stage: "라포",
|
|
effective_openness: 0.34,
|
|
turn_seq: turnSeq,
|
|
safety_flagged: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function fulfillTurn(
|
|
route: Route,
|
|
tokens: string[],
|
|
done: Record<string, unknown>,
|
|
) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: sseTurnBody(tokens, done),
|
|
});
|
|
}
|
|
|
|
interface DetailTurn {
|
|
speaker: "learner" | "client";
|
|
text: string;
|
|
turn_seq: number;
|
|
at: number;
|
|
}
|
|
|
|
/* 자동 스크롤 검증용 — 스크롤포트를 확실히 넘치게 하는 14턴 복원 대화 */
|
|
const LONG_TURNS: DetailTurn[] = Array.from({ length: 14 }, (_, index) => {
|
|
const learner = index % 2 === 0;
|
|
return {
|
|
speaker: learner ? "learner" : "client",
|
|
text: learner
|
|
? `그 순간에 어떤 생각이 스쳐 지나갔는지 조금 더 자세히 말해 줄 수 있을까요? 편한 만큼만 천천히 들려주세요. (${index + 1})`
|
|
: `학교에 가려고 하면 가슴이 답답해지고, 아무 일도 없는데 눈물이 나요. 그런 제 모습이 스스로도 낯설어요. (${index + 1})`,
|
|
turn_seq: index + 1,
|
|
at: 20 + index * 10,
|
|
};
|
|
});
|
|
|
|
async function routeSessionScreen(
|
|
page: Page,
|
|
options: { detailTurns?: DetailTurn[] } = {},
|
|
) {
|
|
const startedAt = new Date(Date.now() - 300_000);
|
|
|
|
// catch-all 을 먼저 — 나중 등록이 우선이라 아래 개별 fixture 가 이긴다.
|
|
await page.route("**/api/**", (route) =>
|
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
|
);
|
|
|
|
await page.route("**/api/auth/me", (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
user_id: "00000000-0000-0000-0000-0000000uce01",
|
|
email: "uc.learner@hs.ac.kr",
|
|
display_name: "학습자",
|
|
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: "학습자",
|
|
self_introduction: "회기 대화 유스케이스 검증용 학습자입니다.",
|
|
avatar_url: "",
|
|
}),
|
|
),
|
|
);
|
|
|
|
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
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", (route) =>
|
|
route.fulfill(
|
|
jsonRoute([
|
|
{
|
|
code: "P1",
|
|
display_name: "민서(청소년 우울)",
|
|
difficulty: "hard",
|
|
theory_target: ["humanistic"],
|
|
demographics: { age_band: "10대" },
|
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
|
voice_preset: "soft-young-fem",
|
|
source: "database",
|
|
degraded: false,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
// 음성은 이 스펙의 대상이 아니다 — provider 미설정으로 고정해 텍스트 폴백 경로만 쓴다.
|
|
await page.route("**/api/voice/health", (route) =>
|
|
route.fulfill(jsonRoute({ available: false, reason: "provider key not configured" })),
|
|
);
|
|
await page.route("**/api/voice/speech", (route) =>
|
|
route.fulfill(jsonRoute({ detail: "voice synthesis disabled in fixture" }, 503)),
|
|
);
|
|
|
|
await page.route("**/api/sessions", async (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
await route.fulfill(
|
|
jsonRoute(
|
|
{
|
|
session_id: SESSION_ID,
|
|
case_id: "uc-conv-case-001",
|
|
session_no: 1,
|
|
stage: "라포",
|
|
effective_openness: 0.21,
|
|
recall_summary: null,
|
|
degraded: false,
|
|
},
|
|
201,
|
|
),
|
|
);
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
session_id: SESSION_ID,
|
|
case_id: "uc-conv-case-001",
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: 1,
|
|
status: "active",
|
|
stage: "라포",
|
|
theory_mode: "humanistic",
|
|
effective_openness: 0.34,
|
|
started_at: startedAt.toISOString(),
|
|
ended_at: null,
|
|
review_ready: false,
|
|
turns: (options.detailTurns ?? []).map((turn) => ({
|
|
speaker: turn.speaker,
|
|
text: turn.text,
|
|
turn_seq: turn.turn_seq,
|
|
created_at: new Date(startedAt.getTime() + turn.at * 1000).toISOString(),
|
|
})),
|
|
}),
|
|
),
|
|
);
|
|
|
|
// 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상은
|
|
// 대화 왕복이고, 점검 잠금 자체는 session-mvp.spec.ts 가 검증한다.
|
|
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
|
if (route.request().method() === "POST") {
|
|
await route.fulfill(
|
|
jsonRoute(
|
|
{ pulse_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", status: "awaiting_agents" },
|
|
202,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
await route.fulfill(
|
|
jsonRoute({
|
|
items: [
|
|
{
|
|
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
checkpoint: "pre",
|
|
status: "ready",
|
|
learner_locked_at: startedAt.toISOString(),
|
|
revealed_at: startedAt.toISOString(),
|
|
error_code: null,
|
|
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
|
measurements: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
source: "database",
|
|
quota: { remaining: 3, max: 3 },
|
|
credit_events: [],
|
|
events: [],
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
interface StreamController {
|
|
readonly calls: number;
|
|
readonly texts: string[];
|
|
}
|
|
|
|
async function installStream(
|
|
page: Page,
|
|
respond: (index: number, route: Route) => Promise<void> | void,
|
|
): Promise<StreamController> {
|
|
const state = { calls: 0, texts: [] as string[] };
|
|
await page.route(`**/api/sessions/${SESSION_ID}/stream`, async (route) => {
|
|
const index = state.calls;
|
|
state.calls += 1;
|
|
const body = route.request().postDataJSON() as { text?: string } | null;
|
|
state.texts.push(body?.text ?? "");
|
|
await respond(index, route);
|
|
});
|
|
return {
|
|
get calls() {
|
|
return state.calls;
|
|
},
|
|
get texts() {
|
|
return state.texts;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function startFreshSession(page: Page) {
|
|
await page.goto("/learn/session/P1");
|
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
}
|
|
|
|
async function resumeSession(page: Page) {
|
|
await page.goto(`/learn/session/${SESSION_ID}`);
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
}
|
|
|
|
function distanceFromBottom(page: Page) {
|
|
return page.evaluate(() => {
|
|
const el = document.querySelector(".sx-transcript__scroll");
|
|
if (!el) return Number.MAX_SAFE_INTEGER;
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
});
|
|
}
|
|
|
|
const clientUtterance = (page: Page) =>
|
|
page.locator(".sx-utt.is-client:not(.is-thinking)");
|
|
const learnerUtterance = (page: Page) => page.locator(".sx-utt.is-learner");
|
|
const composerOf = (page: Page) => page.getByLabel("학습자 발화 입력");
|
|
const sendButtonOf = (page: Page) => page.getByRole("button", { name: "보내기" });
|
|
|
|
test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
|
|
// usecase: 학습자가 아무것도 쓰지 않거나 공백만 쓴 채로는 발화를 보낼 수 없다
|
|
test("빈 입력·공백 입력으로는 발화가 전송되지 않는다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, (_, route) =>
|
|
fulfillTurn(route, REPLY_TOKENS, doneEvent(1)),
|
|
);
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
const sendButton = sendButtonOf(page);
|
|
|
|
await expect(sendButton).toBeDisabled();
|
|
await composer.fill(" ");
|
|
await expect(sendButton).toBeDisabled();
|
|
await composer.press("Enter");
|
|
|
|
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
|
expect(stream.calls).toBe(0);
|
|
|
|
// 실제 문장을 쓰면 그제야 보내기가 살아난다.
|
|
await composer.fill(LEARNER_TEXT);
|
|
await expect(sendButton).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 학습자가 발화를 보내면 응답을 기다리기 전에 내 발화가 즉시 자막에 남는다
|
|
test("전송 즉시 학습자 발화가 자막에 표시되고 입력창이 비워진다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const gate = deferred();
|
|
await installStream(page, async (_, route) => {
|
|
await gate.promise;
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
// 서버 응답 전인데도 낙관적으로 학습자 발화가 먼저 보인다.
|
|
const myUtterance = learnerUtterance(page).filter({ hasText: LEARNER_TEXT });
|
|
await expect(myUtterance).toBeVisible();
|
|
await expect(myUtterance).toContainText("나 (학습자)");
|
|
await expect(composer).toHaveValue("");
|
|
await expect(
|
|
page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." }),
|
|
).toBeVisible();
|
|
|
|
gate.resolve();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
});
|
|
|
|
// usecase: 내담자 응답이 여러 SSE 조각으로 흘러와도 하나의 말풍선에 이어 붙어 보인다
|
|
test("여러 token delta 가 하나의 내담자 말풍선으로 누적 렌더된다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
await installStream(page, (_, route) =>
|
|
fulfillTurn(
|
|
route,
|
|
["그 말을 들으니 ", "마음이 조금 ", "놓여요."],
|
|
doneEvent(1, { stage: "탐색", effective_openness: 0.42 }),
|
|
),
|
|
);
|
|
await startFreshSession(page);
|
|
|
|
await composerOf(page).fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
const clientBubble = clientUtterance(page);
|
|
await expect(clientBubble).toHaveCount(1);
|
|
await expect(clientBubble.locator(".sx-utt__line")).toHaveText(
|
|
"그 말을 들으니 마음이 조금 놓여요.",
|
|
);
|
|
// done 이후에는 진행 중(partial) 표시가 남지 않는다.
|
|
await expect(page.locator(".sx-utt.is-partial")).toHaveCount(0);
|
|
});
|
|
|
|
// usecase: 한 턴이 끝나면 학습자는 바로 다음 발화를 이어서 보낼 수 있다
|
|
test("done 후 보내기가 재활성화되어 두 번째 턴을 이어갈 수 있다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, async (index, route) => {
|
|
if (index === 0) {
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
return;
|
|
}
|
|
await fulfillTurn(
|
|
route,
|
|
["오늘은 조금 나은 것 같아요."],
|
|
doneEvent(2, { stage: "탐색", effective_openness: 0.45 }),
|
|
);
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
const sendButton = sendButtonOf(page);
|
|
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButton.click();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
|
|
const secondText = "그 마음이 언제부터 시작됐는지 기억나는 장면이 있을까요?";
|
|
await composer.fill(secondText);
|
|
await expect(sendButton).toBeEnabled();
|
|
await sendButton.click();
|
|
|
|
await expect(
|
|
clientUtterance(page).filter({ hasText: "오늘은 조금 나은 것 같아요." }),
|
|
).toBeVisible();
|
|
await expect(learnerUtterance(page)).toHaveCount(2);
|
|
expect(stream.calls).toBe(2);
|
|
expect(stream.texts).toEqual([LEARNER_TEXT, secondText]);
|
|
});
|
|
|
|
// usecase: 응답이 생성되는 동안 학습자가 조급하게 연타해도 턴이 중복 전송되지 않는다
|
|
test("스트림 중에는 보내기가 비활성화되고 Enter 연타도 중복 전송되지 않는다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page);
|
|
const gate = deferred();
|
|
const stream = await installStream(page, async (_, route) => {
|
|
await gate.promise;
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
const sendButton = sendButtonOf(page);
|
|
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButton.click();
|
|
await expect(sendButton).toBeDisabled();
|
|
|
|
// 대기 중에도 다음 발화 작성은 허용되지만, Enter 연타는 직렬 턴 계약에 막힌다.
|
|
const draft = "혹시 지금도 그 생각이 이어지고 있나요?";
|
|
await composer.fill(draft);
|
|
await composer.press("Enter");
|
|
await composer.press("Enter");
|
|
expect(stream.calls).toBe(1);
|
|
|
|
gate.resolve();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
// 연타한 초안은 전송되지 않고 작성 중 상태로 남아 있어야 한다.
|
|
await expect(composer).toHaveValue(draft);
|
|
expect(stream.calls).toBe(1);
|
|
await expect(learnerUtterance(page)).toHaveCount(1);
|
|
await expect(learnerUtterance(page).filter({ hasText: draft })).toHaveCount(0);
|
|
});
|
|
|
|
// usecase: 학습자가 아주 긴 발화(2000자)를 정리해 보내도 잘리지 않고 그대로 전달된다
|
|
test("2000자 긴 발화도 잘리지 않고 전송·표시된다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, (_, route) =>
|
|
fulfillTurn(route, ["긴 이야기를 끝까지 들려줘서 고마워요."], doneEvent(1)),
|
|
);
|
|
await startFreshSession(page);
|
|
|
|
const longText = "오늘있었던일을차분히말해봅니다".repeat(134).slice(0, 2000);
|
|
expect(longText).toHaveLength(2000);
|
|
|
|
await composerOf(page).fill(longText);
|
|
await sendButtonOf(page).click();
|
|
|
|
await expect(
|
|
learnerUtterance(page).filter({ hasText: longText.slice(0, 30) }),
|
|
).toBeVisible();
|
|
await expect(
|
|
clientUtterance(page).filter({ hasText: "긴 이야기를 끝까지 들려줘서 고마워요." }),
|
|
).toBeVisible();
|
|
expect(stream.texts[0]).toHaveLength(2000);
|
|
expect(stream.texts[0]).toBe(longText);
|
|
|
|
const bubbleMetrics = await learnerUtterance(page)
|
|
.locator(".sx-utt__line")
|
|
.evaluate((element) => {
|
|
const bubble = element as HTMLElement;
|
|
const utterance = bubble.closest<HTMLElement>(".sx-utt");
|
|
const body = bubble.closest<HTMLElement>(".sx-utt__body");
|
|
if (!utterance || !body) return null;
|
|
|
|
const controls = Array.from(body.children).filter((child) => child !== bubble);
|
|
const controlWidth = controls.reduce(
|
|
(total, child) => total + (child as HTMLElement).getBoundingClientRect().width,
|
|
0,
|
|
);
|
|
const gap = Number.parseFloat(getComputedStyle(body).gap) || 0;
|
|
|
|
return {
|
|
availableWidth:
|
|
utterance.getBoundingClientRect().width - controlWidth - gap * controls.length,
|
|
bubbleWidth: bubble.getBoundingClientRect().width,
|
|
bubbleClientWidth: bubble.clientWidth,
|
|
bubbleScrollWidth: bubble.scrollWidth,
|
|
};
|
|
});
|
|
|
|
expect(bubbleMetrics).not.toBeNull();
|
|
expect(bubbleMetrics!.bubbleWidth).toBeGreaterThanOrEqual(bubbleMetrics!.availableWidth - 1);
|
|
expect(bubbleMetrics!.bubbleScrollWidth).toBeLessThanOrEqual(bubbleMetrics!.bubbleClientWidth + 1);
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
// usecase: 전송이 서버 500 으로 거절되면 학습자의 작성문이 사라지지 않고 오류를 안내받는다
|
|
test("전송이 500으로 실패하면 작성문이 보존되고 오류가 표시된다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, (_, route) =>
|
|
route.fulfill(jsonRoute({ detail: "uc fixture internal failure" }, 500)),
|
|
);
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
const turnAlert = page
|
|
.getByRole("alert")
|
|
.filter({ hasText: "내담자 응답을 생성하지 못했습니다" });
|
|
await expect(turnAlert).toBeVisible();
|
|
await expect(turnAlert).toContainText("엔진 연결");
|
|
// 실패한 턴은 축어록에 남지 않고, 작성문은 재시도할 수 있게 복원된다.
|
|
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
|
await expect(composer).toHaveValue(LEARNER_TEXT);
|
|
await expect(sendButtonOf(page)).toBeEnabled();
|
|
expect(stream.calls).toBe(1);
|
|
});
|
|
|
|
// usecase: 스트림 도중 오류가 나면 실패로 안내받고, 부분 응답이 성공처럼 남지 않으며 재시도로 회복한다
|
|
test("스트림 도중 오류 이벤트가 오면 실패로 표시하고 재시도로 회복한다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page);
|
|
const partialText = "그게 무슨 말인지 잘";
|
|
const stream = await installStream(page, async (index, route) => {
|
|
if (index === 0) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: [
|
|
"event: token",
|
|
`data: ${partialText}`,
|
|
"",
|
|
"event: error",
|
|
'data: {"detail":"uc fixture stream cut"}',
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
return;
|
|
}
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
// 실패 상태를 정직하게 알린다 — 부분 응답을 성공으로 위장하지 않는다.
|
|
await expect(
|
|
page.getByRole("alert").filter({ hasText: "내담자 응답을 생성하지 못했습니다" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
|
await expect(page.getByText(partialText)).toHaveCount(0);
|
|
await expect(composer).toHaveValue(LEARNER_TEXT);
|
|
|
|
// 같은 작성문으로 재시도하면 정상 왕복으로 회복한다.
|
|
await sendButtonOf(page).click();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
await expect(
|
|
page.getByRole("alert").filter({ hasText: "내담자 응답을 생성하지 못했습니다" }),
|
|
).toHaveCount(0);
|
|
expect(stream.calls).toBe(2);
|
|
});
|
|
|
|
// usecase: HTTP 200 스트림이 오류 이벤트 없이 끊겨도 성공으로 오인하지 않고 같은 발화로 회복한다
|
|
test("done 없이 스트림이 끝나면 실패로 표시하고 같은 발화 재시도를 보장한다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page);
|
|
const partialText = "연결이 끊기기 전 일부 응답";
|
|
const stream = await installStream(page, async (index, route) => {
|
|
if (index === 0) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: ["event: token", `data: ${partialText}`, ""].join("\n"),
|
|
});
|
|
return;
|
|
}
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
const alert = page.getByRole("alert").filter({ hasText: "응답 연결이 중간에 끊겼습니다" });
|
|
await expect(alert).toBeVisible();
|
|
await expect(alert).toContainText("입력 내용은 복원했습니다");
|
|
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
|
await expect(page.getByText(partialText)).toHaveCount(0);
|
|
await expect(composer).toHaveValue(LEARNER_TEXT);
|
|
|
|
await sendButtonOf(page).click();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
await expect(alert).toHaveCount(0);
|
|
expect(stream.calls).toBe(2);
|
|
});
|
|
|
|
// usecase: 내담자가 응답을 내지 못한 턴은 빈 말풍선 없이 정직하게 끝나고 다음 발화를 이어갈 수 있다
|
|
test("빈 내담자 응답 턴은 가짜 말풍선 없이 종료되고 대화를 이어갈 수 있다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, async (index, route) => {
|
|
if (index === 0) {
|
|
await fulfillTurn(route, [], doneEvent(1));
|
|
return;
|
|
}
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(2));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
// 학습자 발화는 저장돼 남고, 내담자 말풍선·대기 표시는 만들어지지 않는다.
|
|
await expect(learnerUtterance(page).filter({ hasText: LEARNER_TEXT })).toBeVisible();
|
|
await expect(page.locator(".sx-utt.is-thinking")).toHaveCount(0);
|
|
await expect(clientUtterance(page)).toHaveCount(0);
|
|
|
|
// 대화는 막히지 않는다 — 다음 발화로 정상 왕복.
|
|
await composer.fill("천천히 정리해서 다시 이야기해 볼까요?");
|
|
await expect(sendButtonOf(page)).toBeEnabled();
|
|
await sendButtonOf(page).click();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
expect(stream.calls).toBe(2);
|
|
});
|
|
|
|
// usecase: 학습자가 Enter 로 발화를 보내고, Shift+Enter 로는 줄바꿈만 한다
|
|
test("Enter 는 전송, Shift+Enter 는 줄바꿈으로 동작한다", async ({ page }) => {
|
|
await routeSessionScreen(page);
|
|
const stream = await installStream(page, (_, route) =>
|
|
fulfillTurn(route, REPLY_TOKENS, doneEvent(1)),
|
|
);
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill("첫 마음은 여기까지 이야기했어요.");
|
|
await composer.press("Shift+Enter");
|
|
await composer.pressSequentially("이어서 두 번째 마음도 있어요.");
|
|
|
|
// Shift+Enter 는 전송하지 않고 줄바꿈만 추가한다.
|
|
expect(stream.calls).toBe(0);
|
|
await expect(composer).toHaveValue(
|
|
"첫 마음은 여기까지 이야기했어요.\n이어서 두 번째 마음도 있어요.",
|
|
);
|
|
|
|
await composer.press("Enter");
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
expect(stream.calls).toBe(1);
|
|
expect(stream.texts[0]).toBe(
|
|
"첫 마음은 여기까지 이야기했어요.\n이어서 두 번째 마음도 있어요.",
|
|
);
|
|
});
|
|
|
|
// usecase: 대화가 길어져도 새 내담자 응답이 오면 자막이 최신 발화 위치로 따라간다
|
|
test("새 응답이 도착하면 자막이 자동으로 최신 위치까지 스크롤된다", async ({ page }) => {
|
|
await routeSessionScreen(page, { detailTurns: LONG_TURNS });
|
|
await installStream(page, (_, route) =>
|
|
fulfillTurn(
|
|
route,
|
|
["말씀을 듣고 보니 오늘은 저도 제 마음을 조금 더 열어 보고 싶어졌어요."],
|
|
doneEvent(LONG_TURNS.length + 2),
|
|
),
|
|
);
|
|
await resumeSession(page);
|
|
|
|
// 복원 직후에도 최신 발화를 따라간다. (스크롤포트가 실제로 넘치는지 먼저 보증)
|
|
const overflow = await page.evaluate(() => {
|
|
const el = document.querySelector(".sx-transcript__scroll");
|
|
if (!el) return 0;
|
|
return el.scrollHeight - el.clientHeight;
|
|
});
|
|
expect(overflow).toBeGreaterThan(50);
|
|
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
|
|
|
await composerOf(page).fill("지금 이야기하면서 어떤 느낌이 드는지 궁금해요.");
|
|
await sendButtonOf(page).click();
|
|
await expect(
|
|
clientUtterance(page).filter({ hasText: "마음을 조금 더 열어 보고 싶어졌어요." }),
|
|
).toBeVisible();
|
|
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
|
});
|
|
|
|
// usecase: 위로 올려 이전 대화를 읽는 동안에는 새 응답이 와도 시선을 빼앗지 않고, 최신으로 버튼으로 복귀한다
|
|
test("위로 스크롤해 읽는 중에는 자동 스크롤이 멈추고 최신으로 버튼으로 복귀한다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page, { detailTurns: LONG_TURNS });
|
|
await installStream(page, (_, route) =>
|
|
fulfillTurn(
|
|
route,
|
|
["그때 이야기를 다시 꺼내 줘서 고마워요. 조금 더 말해 볼게요."],
|
|
doneEvent(LONG_TURNS.length + 2),
|
|
),
|
|
);
|
|
await resumeSession(page);
|
|
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
|
|
|
// 사용자가 직접(wheel 의도) 맨 위로 이동해 과거 발화를 읽는다.
|
|
await page.evaluate(() => {
|
|
const el = document.querySelector<HTMLElement>(".sx-transcript__scroll");
|
|
if (!el) return;
|
|
el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -400 }));
|
|
el.scrollTop = 0;
|
|
});
|
|
const jumpButton = page.getByRole("button", { name: "최신으로" });
|
|
await expect(jumpButton).toBeVisible();
|
|
|
|
// 읽는 중 새 턴이 진행돼도 화면 위치를 빼앗지 않는다.
|
|
await composerOf(page).fill("방금 하신 말이 마음에 남아서요.");
|
|
await sendButtonOf(page).click();
|
|
await expect(
|
|
clientUtterance(page).filter({ hasText: "조금 더 말해 볼게요." }),
|
|
).toBeVisible();
|
|
const scrollTop = await page.evaluate(
|
|
() => document.querySelector(".sx-transcript__scroll")?.scrollTop ?? -1,
|
|
);
|
|
expect(scrollTop).toBeLessThan(50);
|
|
await expect.poll(() => distanceFromBottom(page)).toBeGreaterThan(200);
|
|
|
|
// 최신으로 버튼을 누르면 다시 하단 추적 모드로 돌아간다.
|
|
await jumpButton.click();
|
|
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
|
await expect(jumpButton).toBeHidden();
|
|
});
|
|
|
|
// usecase: 저장 마스킹된 [NAME] 토큰이 학습자에게는 자연스러운 한국어 문장으로 보인다
|
|
test("저장된 회기의 [NAME] 마스킹이 자연어 문장으로 표시된다", async ({ page }) => {
|
|
await routeSessionScreen(page, {
|
|
detailTurns: [
|
|
{
|
|
speaker: "learner",
|
|
text: "어머니 이야기를 조금 더 들려줄 수 있을까요?",
|
|
turn_seq: 1,
|
|
at: 30,
|
|
},
|
|
{
|
|
speaker: "client",
|
|
text: "[NAME]은 제 얘기를 끝까지 듣지 않아요. [NAME]이 자꾸 저를 몰아세워요.",
|
|
turn_seq: 2,
|
|
at: 45,
|
|
},
|
|
],
|
|
});
|
|
await resumeSession(page);
|
|
|
|
const transcript = page.locator(".sx-transcript__scroll");
|
|
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 내담자는, [NAME]이→익명 내담자가.
|
|
await expect(transcript).toContainText("익명 내담자는 제 얘기를 끝까지 듣지 않아요.");
|
|
await expect(transcript).toContainText("익명 내담자가 자꾸 저를 몰아세워요.");
|
|
await expect(transcript).not.toContainText("[NAME]");
|
|
});
|
|
|
|
// usecase: 새 응답을 기다리는 동안에도 학습자는 다음 발화를 미리 작성할 수 있다 (입력창은 살아 있다)
|
|
test("응답 대기 중에도 입력창은 열려 있어 다음 발화를 미리 작성할 수 있다", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionScreen(page);
|
|
const gate = deferred();
|
|
await installStream(page, async (_, route) => {
|
|
await gate.promise;
|
|
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
|
});
|
|
await startFreshSession(page);
|
|
|
|
const composer = composerOf(page);
|
|
await composer.fill(LEARNER_TEXT);
|
|
await sendButtonOf(page).click();
|
|
|
|
// 직렬 턴 계약: 보내기는 잠기지만 작성 자체는 막지 않는다(voiceCapture.ts 계약).
|
|
await expect(sendButtonOf(page)).toBeDisabled();
|
|
await expect(composer).toBeEnabled();
|
|
const draft = "다음에는 그날의 장면을 함께 그려 보고 싶어요.";
|
|
await composer.fill(draft);
|
|
|
|
gate.resolve();
|
|
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
|
// 미리 쓴 초안은 응답 도착 후에도 지워지지 않고, 이제 보낼 수 있다.
|
|
await expect(composer).toHaveValue(draft);
|
|
await expect(sendButtonOf(page)).toBeEnabled();
|
|
});
|
|
});
|