955 lines
35 KiB
TypeScript
955 lines
35 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const sessionId = "33333333-3333-4333-8333-333333333333";
|
|
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
|
const clientReply = "괜찮아요. 천천히 말해볼게요.";
|
|
|
|
declare global {
|
|
interface Window {
|
|
__voiceErrorFixture?: {
|
|
releaseError: () => void;
|
|
sent: string[];
|
|
};
|
|
__voiceCrisisFixture?: {
|
|
sent: string[];
|
|
};
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
|
|
const sessionStartRequests: unknown[] = [];
|
|
const liveCoachRequests: unknown[] = [];
|
|
let liveCoachHistoryRequests = 0;
|
|
let deliveredCoachSuggestion: Record<string, unknown> | null = null;
|
|
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/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: "자퇴와 무기력감을 둘러싼 상담 연습",
|
|
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") {
|
|
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,
|
|
}),
|
|
});
|
|
});
|
|
|
|
return {
|
|
sessionStartRequests,
|
|
liveCoachRequests,
|
|
streamGate,
|
|
streamSeen,
|
|
get liveCoachHistoryRequests() {
|
|
return liveCoachHistoryRequests;
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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("평가 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 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 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 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");
|
|
});
|
|
});
|