vignette/apps/web/e2e/session-mvp.spec.ts
2026-08-09 18:22:03 +09:00

1619 lines
61 KiB
TypeScript

import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { parseVoicePracticeContext } from "../src/lib/voicePracticeContext";
import { expectNoHorizontalOverflow } from "./support";
const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
declare global {
interface Window {
__voiceErrorFixture?: {
releaseError: () => void;
sent: string[];
};
__voiceCrisisFixture?: {
sent: string[];
};
__voiceLifecycleFixture?: {
connections: number;
sent: string[];
releaseFinal: () => void;
dropBeforeReply: () => void;
releaseSavedReplyDegraded: () => void;
releaseEmptyFinal: () => void;
};
}
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
interface RouteMvpOptions {
endStatus?: number;
endBody?: unknown;
crisisStream?: boolean;
liveCoachStatus?: "ready" | "degraded";
liveCoachHistoryStatus?: number;
liveCoachHistorySource?: "database" | "runtime";
liveCoachPersistenceSource?: "database" | "runtime";
liveCoachHistoryQuotas?: Array<{ remaining: number; max: number }>;
liveCoachSuggestionQuota?: { remaining: number; max: number };
alliancePreLocked?: boolean;
}
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
const sessionStartRequests: unknown[] = [];
const liveCoachRequests: unknown[] = [];
const alliancePulseRequests: Array<{ checkpoint?: string }> = [];
const alliancePulseItems: Array<Record<string, unknown>> = [];
let liveCoachHistoryRequests = 0;
let deliveredCoachSuggestion: Record<string, unknown> | null = null;
const streamSeen = deferred();
const streamGate = deferred();
if (options.alliancePreLocked !== false) {
alliancePulseItems.push({
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/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/voice/health", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: true, reason: null }),
});
});
// 텍스트 턴 TTS는 이 fixture의 검증 대상이 아니다. 실제 8000 포트로 새지 않게
// 명시적으로 실패시키고, 음성 실패가 작성 중인 초안을 지우지 않는지만 본다.
await page.route("**/api/voice/speech", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }),
});
});
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: [],
}),
});
});
// 이 파일은 코칭/위기/종료 회귀를 검증한다. 새 회기 전 펄스 자체는
// alliance-checkpoint 전용 시나리오에서 다루고, 여기서는 이미 잠긴 원장을 제공한다.
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
checkpoint?: "pre" | "mid" | "post";
scores?: Record<string, number>;
};
alliancePulseRequests.push(body);
const pulseId =
body.checkpoint === "mid"
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
alliancePulseItems.push({
pulse_id: pulseId,
checkpoint: body.checkpoint,
status: "awaiting_agents",
learner_locked_at: new Date().toISOString(),
revealed_at: null,
error_code: null,
self_scores: body.scores,
measurements: [],
});
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ pulse_id: pulseId, status: "awaiting_agents" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: alliancePulseItems,
}),
});
});
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") {
const quota =
options.liveCoachHistoryQuotas?.[
Math.min(liveCoachHistoryRequests, options.liveCoachHistoryQuotas.length - 1)
] ?? { remaining: 3, max: 3 };
liveCoachHistoryRequests += 1;
if (options.liveCoachHistoryStatus && options.liveCoachHistoryStatus >= 400) {
await route.fulfill({
status: options.liveCoachHistoryStatus,
contentType: "application/json",
body: JSON.stringify({ detail: "live coach history unavailable" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: options.liveCoachHistorySource ?? "database",
quota,
credit_events: [],
events: deliveredCoachSuggestion
? [
{
event_id: "fixture-coach-1",
session_id: sessionId,
turn_seq: 1,
stage: "탐색",
created_at: "2026-06-26T00:00:03.000Z",
learner_text_excerpt: learnerText,
client_reply_excerpt: clientReply,
suggestion: deliveredCoachSuggestion,
},
]
: [],
}),
});
return;
}
liveCoachRequests.push(request.postDataJSON());
deliveredCoachSuggestion = {
status: options.liveCoachStatus ?? "ready",
tone: "pos",
focus: "reflection",
title: "감정 반영이 선명합니다",
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
rationale:
options.liveCoachStatus === "degraded"
? "AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 판단입니다."
: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
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: options.liveCoachStatus === "degraded" ? 0 : 12,
persistence_source: options.liveCoachPersistenceSource ?? "database",
quota: options.liveCoachSuggestionQuota ?? { remaining: 2, max: 3 },
credit_events: [],
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(deliveredCoachSuggestion),
});
});
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,
}),
});
});
await page.route(
`**/api/sessions/${sessionId}/multimodal-alliance/consent`,
async (route) => {
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: consentBody.submission_id,
consent_snapshot_id: "00000000-0000-4000-8000-000000000777",
consent_status: "granted",
deletion_request_id: null,
idempotent_replay: false,
}),
});
},
);
return {
sessionStartRequests,
liveCoachRequests,
alliancePulseRequests,
streamGate,
streamSeen,
get liveCoachHistoryRequests() {
return liveCoachHistoryRequests;
},
};
}
async function acceptVoiceInputConsent(page: Page) {
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(consentDialog).toBeVisible();
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
await expect(consentDialog).toContainText("동의하지 않아도 텍스트로 계속할 수 있습니다");
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect(consentDialog).toHaveCount(0);
}
async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const fixture = {
sent: [] as string[],
socket: null as FixtureWebSocket | null,
releaseError() {
const socket = fixture.socket;
if (!socket || socket.readyState >= FixtureWebSocket.CLOSING) return;
socket.emitJson({
type: "error",
code: "turn_persistence_unavailable",
detail: "voice turn persistence unavailable; retry the utterance",
});
socket.close(1000);
},
};
window.__voiceErrorFixture = fixture;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: async () => fakeStream },
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(_url: string | URL) {
fixture.socket = this;
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type === "audio_end") {
window.setTimeout(() => {
this.emitJson({ type: "state", state: "thinking" });
this.emitJson({ type: "transcript", text, final: true });
}, 0);
}
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
const event = new MessageEvent("message", { data: JSON.stringify(payload) });
this.onmessage?.(event);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
}, transcriptText);
}
async function installVoiceCrisisFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const crisisResource = {
title: "자살예방상담전화 109",
number: "109",
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
};
const fixture = {
sent: [] as string[],
socket: null as FixtureWebSocket | null,
};
window.__voiceCrisisFixture = fixture;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: async () => fakeStream },
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(_url: string | URL) {
fixture.socket = this;
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type === "audio_end") {
window.setTimeout(() => {
this.emitJson({ type: "state", state: "thinking" });
this.emitJson({ type: "transcript", text, final: true });
this.emitJson({
type: "reply",
text: "",
safety_flagged: true,
crisis_resource: crisisResource,
conversation_stopped: true,
turn_seq: 1,
});
this.emitJson({ type: "state", state: "idle" });
}, 0);
}
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
const event = new MessageEvent("message", { data: JSON.stringify(payload) });
this.onmessage?.(event);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
}, transcriptText);
}
async function installVoiceLifecycleFixture(page: Page) {
await page.addInitScript(() => {
const firstInterim = "요즘 잠을";
const firstFinal = "요즘 잠을 잘 못 자요.";
const secondInterim = "오늘은 조금";
const secondFinal = "오늘은 조금 더 천천히 말해볼게요.";
const savedReply = "그렇게 말해주시니 조금 안심돼요.";
const sockets: FixtureWebSocket[] = [];
const fixture = {
connections: 0,
sent: [] as string[],
releaseFinal() {
const socket = sockets[0];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: firstFinal, final: true });
},
dropBeforeReply() {
const socket = sockets[0];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.close(1011);
},
releaseSavedReplyDegraded() {
const socket = sockets[1];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: secondFinal, final: true });
socket.emitJson({
type: "reply",
text: savedReply,
turn_seq: 2,
stage: "탐색",
effective_openness: 0.52,
});
socket.emitJson({ type: "state", state: "speaking" });
socket.emitJson({ type: "degraded", reason: "TTS failed: provider disconnected" });
},
releaseEmptyFinal() {
const socket = sockets[2];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: "", final: true });
socket.emitJson({ type: "state", state: "idle" });
},
};
window.__voiceLifecycleFixture = fixture;
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
return {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
},
},
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readonly connectionNumber: number;
readonly isVoiceSocket: boolean;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string | URL) {
this.isVoiceSocket = String(url).includes("/voice/ws");
if (!this.isVoiceSocket) {
this.connectionNumber = 0;
return;
}
fixture.connections += 1;
this.connectionNumber = fixture.connections;
sockets.push(this);
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (!this.isVoiceSocket) return;
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type !== "audio_end") return;
window.setTimeout(() => {
if (this.connectionNumber === 1) {
this.emitJson({ type: "transcript", text: firstInterim, final: false });
} else if (this.connectionNumber === 2) {
this.emitJson({ type: "transcript", text: secondInterim, final: false });
}
this.emitJson({
type: "eot",
ready: false,
reason: "insufficient_silence",
silence_ms: 240,
threshold_ms: 700,
});
this.emitJson({ type: "state", state: "listening" });
}, 0);
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
});
}
async function selectAllianceScore(
page: Page,
axis: "목표" | "과업" | "유대",
scoreName: "3 보통이다" | "4 대체로 그렇다",
) {
const group = page.getByRole("group", { name: new RegExp(`^${axis}`) });
await group.getByRole("radio", { name: scoreName }).check();
}
test.describe("P1 MVP core loop", () => {
test("discloses the synthetic client voice before and during use on desktop and mobile", async ({
page,
}, testInfo) => {
const disclosure = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
await page.setViewportSize({ width: 1440, height: 900 });
await routeMvpApi(page);
await page.goto("/learn/session/P1");
const prestartDisclosure = page.locator(".sx-prestart__voice-disclosure");
await expect(prestartDisclosure).toBeVisible();
await expect(prestartDisclosure).toHaveText(disclosure);
await expect(prestartDisclosure).toHaveAttribute("role", "note");
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expect(prestartDisclosure).toBeVisible();
await expectNoHorizontalOverflow(page);
await prestartDisclosure.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-prestart-mobile.png"),
animations: "disabled",
});
await page.getByRole("button", { name: "회기 시작" }).click();
const mobileDisclosure = page.locator(".sx-controlbar__voice-disclosure");
await expect(mobileDisclosure).toBeVisible();
await expect(mobileDisclosure).toHaveText(disclosure);
await expect(page.locator(".sx-mic-block__disclosure")).toBeHidden();
await expectNoHorizontalOverflow(page);
await mobileDisclosure.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-active-mobile.png"),
animations: "disabled",
});
await page.setViewportSize({ width: 1440, height: 900 });
const desktopDisclosure = page.locator(".sx-mic-block__disclosure");
await expect(desktopDisclosure).toBeVisible();
await expect(desktopDisclosure).toHaveText(disclosure);
await expect(mobileDisclosure).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-active-desktop.png"),
animations: "disabled",
fullPage: true,
});
});
test("preserves a transfer prescription through session creation and review", async ({
page,
}, testInfo) => {
await routeMvpApi(page);
const query = new URLSearchParams({
launch: "transfer",
prescription: "transfer-prescription-01",
suite: "transfer-suite-01",
trial: "transfer-trial-01",
source_session: "source-session-01",
criterion: "competency.empathic_reflection",
novelty: "unseen_transfer",
mode: "counterevidence_forecast",
});
await page.goto(`/learn/session/P1?${query}`);
await expect(
page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }),
).toBeVisible();
await expect(page.locator(".sx-practice-launch-context")).toContainText(
"처음 보는 장면",
);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}\\?`));
const persisted = new URL(page.url());
expect(persisted.searchParams.get("launch")).toBe("transfer");
expect(persisted.searchParams.get("prescription")).toBe(
"transfer-prescription-01",
);
expect(persisted.searchParams.get("suite")).toBe("transfer-suite-01");
expect(persisted.searchParams.get("trial")).toBe("transfer-trial-01");
expect(persisted.searchParams.get("source_session")).toBe(
"source-session-01",
);
expect(persisted.searchParams.get("novelty")).toBe("unseen_transfer");
expect(persisted.searchParams.get("mode")).toBe(
"counterevidence_forecast",
);
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review\\?`));
const reviewUrl = new URL(page.url());
expect(reviewUrl.searchParams.get("launch")).toBe("transfer");
expect(reviewUrl.searchParams.get("prescription")).toBe(
"transfer-prescription-01",
);
expect(reviewUrl.searchParams.get("suite")).toBe("transfer-suite-01");
expect(reviewUrl.searchParams.get("trial")).toBe("transfer-trial-01");
await expect(
page.getByRole("heading", {
name: "반대근거 예측 수행 회기의 리뷰입니다.",
}),
).toBeVisible();
const retry = page.getByRole("button", {
name: "같은 전이 과제로 다시 연습",
});
await expect(retry).toBeVisible();
expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("practice-return-review-desktop.png"),
animations: "disabled",
fullPage: true,
});
await retry.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
const retryUrl = new URL(page.url());
expect(retryUrl.searchParams.get("launch")).toBe("transfer");
expect(retryUrl.searchParams.get("trial")).toBe("transfer-trial-01");
});
test("keeps voice provenance legible on mobile and blocks malformed handoffs", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await routeMvpApi(page);
await page.route("**/api/sessions/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
}),
);
await page.route("**/api/sessions", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ sessions: [] }),
});
});
const voiceQuery = new URLSearchParams({
mode: "voice",
source_session: "source-session-01",
source_scene: "oas-g7-event-silence-1",
scene_type: "silence",
scene_start_ms: "18000",
scene_end_ms: "26400",
});
await page.goto(`/learn/session/${sessionId}/review?${voiceQuery}`);
await expect(
page.getByRole("heading", {
name: "침묵 뒤 응답 수행 회기의 리뷰입니다.",
}),
).toBeVisible();
const retry = page.getByRole("button", { name: "같은 장면을 다시 연습" });
await expect(retry).toBeVisible();
expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-practice-return-review-mobile.png"),
animations: "disabled",
fullPage: true,
});
await retry.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
expect(parseVoicePracticeContext(new URL(page.url()).searchParams)).toEqual({
mode: "voice",
sourceSessionId: "source-session-01",
sourceSceneId: "oas-g7-event-silence-1",
sceneType: "silence",
sceneStartMs: 18000,
sceneEndMs: 26400,
});
const malformedQuery = new URLSearchParams({
mode: "voice",
source_session: "source-session-01",
source_scene: "oas-g7-event-silence-1",
scene_type: "silence",
scene_start_ms: "18000",
});
expect(parseVoicePracticeContext(malformedQuery)).toBeNull();
await page.goto(`/learn/practice?${malformedQuery}`);
await expect(
page.getByRole("heading", { name: "원본 회기 정보를 확인할 수 없습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/P1?${malformedQuery}`);
await expect(
page.getByRole("heading", { name: "음성 재연습의 출처를 다시 확인해 주세요." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/${sessionId}/review?${malformedQuery}`);
await expect(
page.getByRole("heading", {
name: "이 리뷰의 연습 출처를 검증할 수 없습니다.",
}),
).toBeVisible();
const recover = page.getByRole("button", { name: "피드백에서 다시 선택" });
await recover.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("tab", { name: "피드백" })).toHaveAttribute(
"aria-selected",
"true",
);
await expectNoHorizontalOverflow(page);
});
test("locks pre before the first turn and offers a persistent mid-session pulse", async ({
page,
}, testInfo) => {
const api = await routeMvpApi(page, { alliancePreLocked: false });
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-pre-${testInfo.project.name}-2026-08-07.png`,
),
fullPage: true,
});
await selectAllianceScore(page, "목표", "3 보통이다");
await selectAllianceScore(page, "과업", "3 보통이다");
await selectAllianceScore(page, "유대", "3 보통이다");
await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click();
await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual(["pre"]);
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
api.streamGate.resolve();
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByRole("button", { name: "30초 점검" }).click();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-mid-${testInfo.project.name}-2026-08-07.png`,
),
fullPage: true,
});
await selectAllianceScore(page, "목표", "4 대체로 그렇다");
await selectAllianceScore(page, "과업", "4 대체로 그렇다");
await selectAllianceScore(page, "유대", "4 대체로 그렇다");
await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click();
await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual([
"pre",
"mid",
]);
await expect(page.getByText("회기 전·중 판단이 원장에 잠겼습니다.")).toBeVisible();
});
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();
const composer = page.getByLabel("학습자 발화 입력");
await expect(composer).toBeEnabled();
await expect(page.getByRole("button", { name: "보내기" })).toBeDisabled();
await composer.fill("다음 질문을 미리 작성합니다.");
api.streamGate.resolve();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await expect(composer).toHaveValue("다음 질문을 미리 작성합니다.");
await expect(page.getByRole("button", { name: "보내기" })).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("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
// ≤1180 폭에서는 리뷰가 가로 탭 — 마지막 내담자 반응은 피드백 탭에 있다.
const feedbackTab = page.locator(".sr-tabs button", { hasText: "피드백" });
if (await feedbackTab.isVisible().catch(() => false)) {
await feedbackTab.click();
}
await expect(page.getByText("마지막 내담자 반응")).toBeVisible();
await expect(page.locator(".sr-feedback").getByText(clientReply)).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();
await page.locator(".sx-coach-card").getByRole("button", { name: "근거 보기" }).click();
const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']");
await expect(evidenceDialog).toBeVisible();
await expect(evidenceDialog).toContainText("0615 사례개념화 워크북");
await expect(evidenceDialog).toContainText("reflection · source_pack · 2026-06-15");
await expect(evidenceDialog).toContainText("허가된 요약 근거");
await evidenceDialog.getByRole("button", { name: "닫기" }).click();
await expect(evidenceDialog).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
expect(api.liveCoachRequests[0]).toMatchObject({
learner_text: learnerText,
client_reply: clientReply,
turn_seq: 1,
});
});
test("refreshes stale empty AI tutor quota before blocking coaching", async ({ page }) => {
const api = await routeMvpApi(page, {
liveCoachHistoryQuotas: [
{ remaining: 0, max: 3 },
{ remaining: 1, max: 3 },
{ remaining: 0, max: 3 },
],
liveCoachSuggestionQuota: { remaining: 0, max: 3 },
});
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();
expect(api.liveCoachRequests).toHaveLength(1);
await expect.poll(() => api.liveCoachHistoryRequests).toBeGreaterThanOrEqual(2);
});
test("shows refreshed AI tutor quota exhaustion over a stale coaching card", async ({
page,
}) => {
const api = await routeMvpApi(page, {
liveCoachHistoryQuotas: [
{ remaining: 0, max: 3 },
{ remaining: 0, max: 3 },
],
liveCoachSuggestionQuota: { remaining: 0, max: 3 },
});
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
expect(api.liveCoachRequests).toHaveLength(1);
await page.getByLabel("학습자 발화 입력").fill("그때 마음을 더 자세히 말해줘도 괜찮아요.");
await page.getByRole("button", { name: "보내기" }).click();
await expect(coachCard.getByText("코칭 기회를 모두 사용했습니다.")).toBeVisible();
await expect(coachCard.getByText("감정 반영이 선명합니다")).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
});
test("surfaces AI tutor history load failure instead of an empty history", async ({ page }) => {
const api = await routeMvpApi(page, { liveCoachHistoryStatus: 503 });
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
await coachCard.getByRole("button", { name: "이력 보기" }).click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toBeVisible();
await expect(historyDialog.getByRole("alert")).toContainText("코칭 이력을 불러오지 못했습니다.");
await expect(historyDialog).not.toContainText("아직 이 턴에 저장된 코칭 이력이 없습니다.");
expect(api.liveCoachRequests).toHaveLength(1);
});
test("labels runtime AI tutor persistence as temporary history", async ({ page }) => {
const api = await routeMvpApi(page, {
liveCoachHistorySource: "runtime",
liveCoachPersistenceSource: "runtime",
});
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
await expect(coachCard).toContainText("임시 이력");
await expect(coachCard).toContainText("DB에 확정 저장되지 않아");
await coachCard.getByRole("button", { name: "이력 보기" }).click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toContainText("임시 저장소 기준");
expect(api.liveCoachRequests).toHaveLength(1);
});
test("marks pending voice transcript as failed when turn persistence fails @single-run", async ({
page,
}) => {
const failedVoiceText = "요즘 잠을 잘 못 자요.";
await routeMvpApi(page);
await installVoicePersistenceFailureFixture(page, failedVoiceText);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText });
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).toHaveClass(/is-partial/);
await expect(
page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." }),
).toBeVisible();
await page.evaluate(() => window.__voiceErrorFixture?.releaseError());
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).toHaveClass(/is-failed/);
await expect(learnerBubble).not.toHaveClass(/is-partial/);
await expect(learnerBubble.getByText("저장 실패 · 다시 시도하세요.")).toBeVisible();
await expect(page.locator(".sx-utt.is-thinking")).toHaveCount(0);
await expect(page.getByRole("alert")).toContainText("음성 발화를 저장하지 못했습니다.");
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await expect(page.locator(".sx-mic-block__l")).toContainText("마이크 오류");
});
test("keeps provider transcript lifecycle visible and offers keyboard-safe voice recovery @single-run", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" });
await routeMvpApi(page);
await installVoiceLifecycleFixture(page);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
const pauseButton = page.getByRole("button", { name: "일시정지" });
await pauseButton.focus();
await pauseButton.press("Space");
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(0);
await page.getByRole("button", { name: "이어가기" }).click();
await page.getByRole("button", { name: "마이크 켜기" }).click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" });
await expect(transcriptLog).toHaveAttribute("aria-live", "polite");
const firstLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({
hasText: "요즘 잠을",
});
await expect(firstLearnerBubble).toHaveCount(1);
await expect(firstLearnerBubble).toHaveClass(/is-partial/);
await expect(firstLearnerBubble.getByText("실시간 전사")).toBeVisible();
await expect(page.locator(".sx-mic-block__h")).toContainText("발화 종료를 확인하지 못했습니다");
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseFinal());
await expect(firstLearnerBubble).toContainText("요즘 잠을 잘 못 자요.");
await expect(firstLearnerBubble.getByText("전사 확정, 응답 연결 중")).toBeVisible();
await expect(transcriptLog.locator(".sx-utt.is-thinking")).toContainText("답변을 준비 중입니다.");
await page.evaluate(() => window.__voiceLifecycleFixture?.dropBeforeReply());
await expect(firstLearnerBubble).toHaveClass(/is-failed/);
await expect(firstLearnerBubble).not.toHaveClass(/is-partial/);
await expect(page.getByRole("alert")).toContainText("음성 연결이 종료되어 발화를 저장하지 못했습니다");
const retryButton = page.getByRole("button", { name: "음성 다시 연결" });
await expect(retryButton).toBeVisible();
const retryBox = await retryButton.boundingBox();
expect(retryBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-recovery-before-reply-mobile-light.png"),
fullPage: true,
});
await retryButton.focus();
await retryButton.press("Space");
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(2);
await page.getByRole("button", { name: "발화 보내기" }).click();
const secondLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({
hasText: "오늘은 조금",
});
await expect(secondLearnerBubble).toHaveCount(1);
await expect(secondLearnerBubble.getByText("실시간 전사")).toBeVisible();
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseSavedReplyDegraded());
await expect(secondLearnerBubble).toContainText("오늘은 조금 더 천천히 말해볼게요.");
await expect(secondLearnerBubble).not.toHaveClass(/is-partial/);
await expect(secondLearnerBubble).not.toHaveClass(/is-failed/);
await expect(transcriptLog.getByText("그렇게 말해주시니 조금 안심돼요.")).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 다시 연결" })).toBeVisible();
await expect(page.locator(".sx-mic-block__h")).toContainText("내담자 응답은 저장됐지만");
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.evaluate(() => {
document.documentElement.setAttribute("data-theme", "dark");
localStorage.setItem("vignette.theme", "dark");
});
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-recovery-after-reply-mobile-dark.png"),
fullPage: true,
});
await page.getByRole("button", { name: "음성 다시 연결" }).click();
await page.getByRole("button", { name: "발화 보내기" }).click();
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseEmptyFinal());
await expect(transcriptLog.locator(".sx-utt.is-learner")).toHaveCount(2);
await expect(page.locator(".sx-mic-block__h")).toContainText("음성을 인식하지 못했습니다");
await expect(transcriptLog.locator(".sx-utt.is-thinking")).toHaveCount(0);
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
});
test("keeps crisis safety gate visible for a voice conversation stop", async ({ page }) => {
const crisisText = "죽고 싶다는 생각이 자꾸 들어요.";
const api = await routeMvpApi(page);
await installVoiceCrisisFixture(page, crisisText);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText });
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).not.toHaveClass(/is-partial/);
await expect(learnerBubble).not.toHaveClass(/is-failed/);
await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible();
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
await expect(page.locator(".sx-utt.is-client")).toHaveCount(0);
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
await expect(page.getByRole("button", { name: "마이크 켜기" })).toBeDisabled();
const sent = await page.evaluate(() => window.__voiceCrisisFixture?.sent ?? []);
expect(sent.some((payload) => payload.includes("\"audio_end\""))).toBe(true);
expect(sent.some((payload) => payload.includes("\"close\""))).toBe(true);
expect(api.liveCoachRequests).toHaveLength(0);
});
test("labels degraded AI tutor fallback as replacement coaching", async ({ page }) => {
const api = await routeMvpApi(page, { liveCoachStatus: "degraded" });
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard).toContainText("대체 코칭");
await expect(coachCard).toContainText("AI 응답 대체");
await expect(coachCard).toContainText("AI 코칭 엔진 응답 대신");
expect(api.liveCoachRequests).toHaveLength(1);
await coachCard.getByRole("button", { name: "근거 보기" }).click();
const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']");
await expect(evidenceDialog).toBeVisible();
await expect(evidenceDialog).toContainText("AI 코칭 엔진 응답 대신");
await expect(evidenceDialog).toContainText("0615 사례개념화 워크북");
await evidenceDialog.getByRole("button", { name: "닫기" }).click();
await expect(evidenceDialog).toHaveCount(0);
const coachMark = page.locator(".sx-utt__coach-mark.is-degraded");
await expect(coachMark).toBeVisible();
await expect(coachMark).toHaveAttribute("title", /AI 응답 대체/);
await coachMark.click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toContainText("AI 응답 대체");
await expect(historyDialog).toContainText("0615 사례개념화 워크북");
});
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");
});
});