859 lines
33 KiB
TypeScript
859 lines
33 KiB
TypeScript
/* =====================================================================
|
|
full-sweep-session.spec.ts — 2026-07-27 전수 순회 체크리스트 §3(상담 세션)
|
|
"검증: 신규 spec 필요" 항목 검증 스펙.
|
|
|
|
대상 checklist id:
|
|
session-guard-pending-approval, session-route-empty-param-redirect,
|
|
session-consent-checkbox, session-consent-save,
|
|
session-time-alarms, session-timebar-end-button,
|
|
session-transcript-autoscroll, session-transcript-empty-state,
|
|
session-crisis-tel-link, session-live-signal, session-coach-nudge,
|
|
session-meters-collapse, session-voice-skip, session-pause-toggle,
|
|
session-keyboard-shortcuts, session-elapsed-live-region
|
|
|
|
session-mvp.spec.ts 의 route fixture / mock WebSocket 패턴을 그대로 따르며
|
|
실제 AI 엔진 턴 생성은 하지 않는다(스트림·음성은 전부 fixture).
|
|
===================================================================== */
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { fetchAvailablePersona, signInAsLearner } from "./support";
|
|
|
|
const fixtureSessionId = "44444444-4444-4444-8444-444444444444";
|
|
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
|
const clientReply = "괜찮아요. 천천히 말해볼게요.";
|
|
const voiceTranscript = "요즘 잠을 잘 못 자요.";
|
|
const voiceClientReply = "말해줘서 고마워요. 조금씩 이야기해볼게요.";
|
|
|
|
declare global {
|
|
interface Window {
|
|
__voiceSpeakFixture?: {
|
|
sent: string[];
|
|
};
|
|
}
|
|
}
|
|
|
|
interface SessionFixtureOptions {
|
|
/** /auth/me 응답 덮어쓰기 (consent_at: null, account_status: "pending" 등) */
|
|
authOverrides?: Record<string, unknown>;
|
|
/** POST /auth/consent 응답 상태 (기본 200) */
|
|
consentStatus?: number;
|
|
/** POST /sessions 시작 응답의 회기 시간 계약값 */
|
|
durationLimitSeconds?: number;
|
|
warningBeforeEndSeconds?: number;
|
|
/** GET /sessions/{id} 재개 스냅샷 덮어쓰기 (status/ended_at/turns 등) */
|
|
detailOverrides?: Record<string, unknown>;
|
|
/** 스트림을 위기 안전게이트 응답으로 대체 */
|
|
crisisStream?: boolean;
|
|
/** 스트림 done 이벤트에 병합할 추가 필드 (progress 등) */
|
|
doneExtra?: Record<string, unknown>;
|
|
}
|
|
|
|
async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions = {}) {
|
|
const liveCoachRequests: unknown[] = [];
|
|
const consentRequests: unknown[] = [];
|
|
|
|
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-0000000fs3e1",
|
|
email: "sweep.session@hs.ac.kr",
|
|
display_name: "Sweep 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: "Sweep Learner",
|
|
self_introduction: "세션 전수 순회 검증용 학습자입니다.",
|
|
avatar_url: "",
|
|
...options.authOverrides,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/auth/consent", async (route) => {
|
|
consentRequests.push(route.request().postDataJSON());
|
|
const status = options.consentStatus ?? 200;
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body:
|
|
status >= 400
|
|
? JSON.stringify({ detail: "consent persistence unavailable" })
|
|
: JSON.stringify({ accepted: true, consent_at: Math.floor(Date.now() / 1000) }),
|
|
});
|
|
});
|
|
|
|
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 사전요청 안전망 — 이 스펙의 done 은 turn_seq 를 싣지 않아
|
|
// 정상 흐름에서는 호출되지 않지만, 호출되더라도 결정론적으로 실패시킨다.
|
|
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;
|
|
}
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
session_id: fixtureSessionId,
|
|
case_id: "sweep-case-001",
|
|
session_no: 1,
|
|
stage: "라포",
|
|
effective_openness: 0.21,
|
|
recall_summary: null,
|
|
degraded: false,
|
|
goal_stages: ["라포", "탐색"],
|
|
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
|
|
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${fixtureSessionId}`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
session_id: fixtureSessionId,
|
|
case_id: "sweep-case-001",
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: 1,
|
|
status: "active",
|
|
stage: "라포",
|
|
theory_mode: "humanistic",
|
|
effective_openness: 0.21,
|
|
started_at: new Date().toISOString(),
|
|
ended_at: null,
|
|
review_ready: false,
|
|
turns: [],
|
|
goal_stages: ["라포", "탐색"],
|
|
progress: null,
|
|
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
|
|
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
|
|
...options.detailOverrides,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${fixtureSessionId}/stream`, async (route) => {
|
|
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: fixtureSessionId,
|
|
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",
|
|
// turn_seq 를 싣지 않아 텍스트 턴 뒤 TTS(speakClientTurn) 경로를 결정론적으로 생략한다.
|
|
`data: ${JSON.stringify({
|
|
session_id: fixtureSessionId,
|
|
stage: "탐색",
|
|
effective_openness: 0.42,
|
|
safety_flagged: false,
|
|
...options.doneExtra,
|
|
})}`,
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${fixtureSessionId}/live-coach`, async (route) => {
|
|
const request = route.request();
|
|
if (request.method() === "GET") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
source: "database",
|
|
quota: { remaining: 3, max: 3 },
|
|
credit_events: [],
|
|
events: [],
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
liveCoachRequests.push(request.postDataJSON());
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
status: "ready",
|
|
tone: "pos",
|
|
focus: "reflection",
|
|
title: "감정 반영이 선명합니다",
|
|
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
|
|
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
|
|
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
|
|
sources: [],
|
|
safety_note: null,
|
|
latency_ms: 12,
|
|
persistence_source: "database",
|
|
quota: { remaining: 2, max: 3 },
|
|
credit_events: [],
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${fixtureSessionId}/end`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
session_id: fixtureSessionId,
|
|
session_no: 1,
|
|
digest_pending: true,
|
|
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
|
|
}),
|
|
});
|
|
});
|
|
|
|
return { liveCoachRequests, consentRequests };
|
|
}
|
|
|
|
/**
|
|
* 음성 마이크·WebSocket mock — session-mvp.spec.ts 패턴.
|
|
* audio_end 수신 시 transcript→reply→state:speaking 을 흘려
|
|
* TTS 재생(voiceStatus=speaking) 구간을 결정론적으로 만든다.
|
|
*/
|
|
async function installVoiceSpeakingFixture(
|
|
page: Page,
|
|
transcriptText: string,
|
|
replyText: string,
|
|
) {
|
|
await page.addInitScript(
|
|
({ text, reply }) => {
|
|
const fixture = { sent: [] as string[] };
|
|
window.__voiceSpeakFixture = 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) {
|
|
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: reply,
|
|
safety_flagged: false,
|
|
turn_seq: 1,
|
|
stage: "라포",
|
|
effective_openness: 0.3,
|
|
});
|
|
this.emitJson({ type: "state", state: "speaking" });
|
|
}, 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,
|
|
});
|
|
},
|
|
{ text: transcriptText, reply: replyText },
|
|
);
|
|
}
|
|
|
|
async function startFixtureSession(page: Page) {
|
|
await page.goto("/learn/session/P1");
|
|
const startButton = page.getByRole("button", { name: "회기 시작" });
|
|
await expect(startButton).toBeEnabled();
|
|
await startButton.click();
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
}
|
|
|
|
test.describe("full sweep — counseling session", () => {
|
|
// checklist: session-guard-pending-approval
|
|
test("redirects a pending-approval learner from the session route to /pending", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page, {
|
|
authOverrides: { account_status: "pending", approval_required: true },
|
|
});
|
|
|
|
await page.goto("/learn/session/P1");
|
|
|
|
await expect(page).toHaveURL(/\/pending$/);
|
|
await expect(page.getByText("승인 대기").first()).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "회기 시작" })).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: session-route-empty-param-redirect
|
|
test("redirects an empty session route param to /learn and initializes persona prestart", async ({
|
|
page,
|
|
}) => {
|
|
// 실 API — dev-login 학습자로 라우팅 규칙만 검증한다(엔진 턴 없음).
|
|
await signInAsLearner(page);
|
|
|
|
await page.goto("/learn/session/%20");
|
|
await expect(page).toHaveURL(/\/learn$/);
|
|
|
|
const persona = await fetchAvailablePersona(page);
|
|
// 소문자 코드로 진입해도 정규화된 대문자 코드로 세션 상태가 초기화된다.
|
|
await page.goto(`/learn/session/${persona.code.toLowerCase()}`);
|
|
await expect(page.getByText(`상담 연습 · ${persona.code.toUpperCase()}`)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
|
|
});
|
|
|
|
// checklist: session-consent-checkbox, session-consent-save
|
|
test("gates consent save behind the checkbox and enables session start after saving", async ({
|
|
page,
|
|
}) => {
|
|
const api = await routeSessionFixtureApi(page, {
|
|
authOverrides: { consent_at: null },
|
|
});
|
|
|
|
await page.goto("/learn/session/P1");
|
|
|
|
const consentBlock = page.locator(".sx-consent");
|
|
await expect(consentBlock).toBeVisible();
|
|
await expect(
|
|
consentBlock.getByText(
|
|
"상담 연습 기록 저장, 개인정보 마스킹 후 평가 AI 처리, 회기 리뷰 생성을 확인합니다.",
|
|
),
|
|
).toBeVisible();
|
|
|
|
const saveButton = page.getByRole("button", { name: "동의 저장" });
|
|
const startButton = page.getByRole("button", { name: "회기 시작" });
|
|
await expect(saveButton).toBeDisabled();
|
|
await expect(startButton).toBeDisabled();
|
|
|
|
await consentBlock.getByRole("checkbox").check();
|
|
await expect(saveButton).toBeEnabled();
|
|
await saveButton.click();
|
|
|
|
await expect(consentBlock).toHaveCount(0);
|
|
await expect(startButton).toBeEnabled();
|
|
expect(api.consentRequests).toHaveLength(1);
|
|
});
|
|
|
|
// checklist: session-consent-save
|
|
test("shows consent save failure message and keeps session start blocked", async ({ page }) => {
|
|
await routeSessionFixtureApi(page, {
|
|
authOverrides: { consent_at: null },
|
|
consentStatus: 503,
|
|
});
|
|
|
|
await page.goto("/learn/session/P1");
|
|
|
|
const consentBlock = page.locator(".sx-consent");
|
|
await consentBlock.getByRole("checkbox").check();
|
|
await page.getByRole("button", { name: "동의 저장" }).click();
|
|
|
|
await expect(
|
|
page.getByText("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요."),
|
|
).toBeVisible();
|
|
await expect(consentBlock).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
|
|
});
|
|
|
|
// checklist: session-time-alarms, session-timebar-end-button
|
|
test("raises the pre-end warning bar, opens the end dialog on time-up, and reopens it from the timebar", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page, {
|
|
durationLimitSeconds: 5,
|
|
warningBeforeEndSeconds: 3,
|
|
});
|
|
|
|
await startFixtureSession(page);
|
|
|
|
const timebar = page.locator(".sx-timebar");
|
|
// 경고 구간(남은 3초 진입, 분 단위 표기로는 1분 전) — role=alert 시간 바가 뜬다.
|
|
await expect(timebar).toBeVisible({ timeout: 10_000 });
|
|
await expect(timebar).toHaveAttribute("role", "alert");
|
|
await expect(timebar).toContainText("종료 1분 전", { timeout: 10_000 });
|
|
|
|
// 시간 만료 — 종료 확인 다이얼로그가 자동으로 열린다.
|
|
const dialogTitle = page.locator("#sx-end-dialog-title");
|
|
await expect(dialogTitle).toHaveText("회기 시간이 끝났어요", { timeout: 10_000 });
|
|
|
|
// 다이얼로그를 닫으면 시간 만료 바의 '종료하고 리뷰 보기' 버튼이 남는다.
|
|
await page.getByRole("button", { name: "마무리 인사 나누기" }).click();
|
|
await expect(dialogTitle).toHaveCount(0);
|
|
await expect(timebar).toContainText("회기 시간이 끝났어요");
|
|
|
|
const timebarEnd = page.locator(".sx-timebar__end");
|
|
await expect(timebarEnd).toHaveText("종료하고 리뷰 보기");
|
|
await timebarEnd.click();
|
|
await expect(dialogTitle).toBeVisible();
|
|
});
|
|
|
|
// checklist: session-transcript-empty-state
|
|
test("shows the waiting empty transcript state before the first utterance", async ({ page }) => {
|
|
await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
|
|
const empty = page.locator(".sx-transcript__empty");
|
|
await expect(empty).toBeVisible();
|
|
await expect(empty).toContainText("민서님이 당신의 첫 질문을 기다리고 있습니다.");
|
|
await expect(empty).toContainText("아래 입력창이나 마이크로 첫 발화를 시작하세요.");
|
|
});
|
|
|
|
// checklist: session-transcript-empty-state
|
|
test("shows the ended-record empty transcript state for an ended session without turns", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page, {
|
|
detailOverrides: {
|
|
status: "ended",
|
|
ended_at: new Date().toISOString(),
|
|
turns: [],
|
|
},
|
|
});
|
|
|
|
await page.goto(`/learn/session/${fixtureSessionId}`);
|
|
|
|
const empty = page.locator(".sx-transcript__empty");
|
|
await expect(empty).toBeVisible();
|
|
await expect(empty).toContainText("종료된 회기 기록입니다.");
|
|
await expect(empty).toContainText("새 발화는 추가하지 않고 리뷰에서 회기를 확인하세요.");
|
|
const compose = page.getByLabel("학습자 발화 입력");
|
|
await expect(compose).toBeDisabled();
|
|
await expect(compose).toHaveAttribute("placeholder", "종료된 회기입니다.");
|
|
});
|
|
|
|
// checklist: session-transcript-autoscroll
|
|
test("releases autoscroll when scrolling up, jumps back with the latest button, and follows new turns", async ({
|
|
page,
|
|
}) => {
|
|
const startedAt = new Date(Date.now() - 600_000).toISOString();
|
|
const turns = Array.from({ length: 30 }, (_, index) => ({
|
|
speaker: index % 2 === 0 ? "learner" : "client",
|
|
text: `이전 회기 발화 ${index + 1} — 자동 스크롤 검증을 위해 충분히 긴 문장을 유지합니다.`,
|
|
turn_seq: index + 1,
|
|
created_at: new Date(Date.now() - 600_000 + (index + 1) * 1000).toISOString(),
|
|
}));
|
|
await routeSessionFixtureApi(page, {
|
|
detailOverrides: { started_at: startedAt, turns },
|
|
});
|
|
|
|
await page.goto(`/learn/session/${fixtureSessionId}`);
|
|
await expect(
|
|
page.locator(".sx-utt__line").filter({ hasText: "이전 회기 발화 30" }),
|
|
).toBeVisible();
|
|
|
|
const scroller = page.locator(".sx-transcript__scroll");
|
|
const jumpButton = page.getByRole("button", { name: "최신으로" });
|
|
await expect(jumpButton).toHaveCount(0);
|
|
|
|
// 위로 스크롤 → 자동 따라가기 해제 + '최신으로' 복귀 버튼 노출.
|
|
await scroller.evaluate((el) => {
|
|
el.scrollTop = 0;
|
|
el.dispatchEvent(new Event("scroll"));
|
|
});
|
|
await expect(jumpButton).toBeVisible();
|
|
|
|
await jumpButton.click();
|
|
await expect(jumpButton).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
|
)
|
|
.toBeLessThan(24);
|
|
|
|
// 새 발화가 오면 하단으로 계속 따라간다.
|
|
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
|
)
|
|
.toBeLessThan(24);
|
|
await expect(jumpButton).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: session-crisis-tel-link
|
|
test("renders the crisis resource number as a tel: link inside the safety panel", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page, { crisisStream: true });
|
|
|
|
await startFixtureSession(page);
|
|
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
|
|
const crisisBlock = page.locator(".sx-crisis-resource");
|
|
await expect(crisisBlock).toBeVisible();
|
|
await expect(crisisBlock).toContainText("자살예방상담전화 109");
|
|
const telLink = crisisBlock.getByRole("link", { name: "109" });
|
|
await expect(telLink).toBeVisible();
|
|
await expect(telLink).toHaveAttribute("href", "tel:109");
|
|
});
|
|
|
|
// checklist: session-live-signal
|
|
// 모바일(≤880px) 압축 레이아웃은 라이브 신호 패널을 의도적으로 숨긴다(session.css) —
|
|
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.
|
|
test("shows live signal dot with sequence, reveals text only in coached mode, and fades", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name.includes("mobile"), "라이브 신호 패널은 모바일에서 의도적으로 숨김");
|
|
await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
await expect(page.locator(".sx-signal__rest")).toContainText("아직 표시할 신호가 없어요");
|
|
|
|
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
|
|
const signalOne = page.locator(".sx-signal__one");
|
|
await expect(signalOne).toBeVisible();
|
|
await expect(signalOne).toHaveAttribute("title", "내담자 응답 수신");
|
|
// 기본 '상태 신호' 모드에서는 텍스트 없이 도트만 노출된다.
|
|
await expect(page.locator(".sx-signal__one-text")).toHaveCount(0);
|
|
await expect(page.locator(".sx-signal__seq-dots i")).toHaveCount(1);
|
|
|
|
// 코칭 모드로 전환하면 신호 텍스트가 노출된다(전환 시 대기 코칭이 즉시 실행됨).
|
|
// 넛지 버튼("... 코칭 열기")과 구분하기 위해 세그먼트의 aria-label 로 지정한다.
|
|
await page.getByRole("button", { name: /코칭 모드, 남은 기회/ }).click();
|
|
await expect(page.locator(".sx-signal__one-text")).toBeVisible();
|
|
// 6초 페이드 — 마지막 신호가 is-faded 로 가라앉는다.
|
|
await expect(signalOne).toHaveClass(/is-faded/, { timeout: 10_000 });
|
|
|
|
// 몰입 모드에서는 라이브 신호 자체를 숨긴다.
|
|
await page.getByRole("button", { name: "몰입" }).click();
|
|
await expect(page.getByText("실시간 신호 없이 대화에만 집중합니다.")).toBeVisible();
|
|
await expect(page.locator(".sx-signal__one")).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: session-coach-nudge
|
|
test("shows the coach nudge for a pending turn and requests coaching immediately when opened", async ({
|
|
page,
|
|
}) => {
|
|
const api = await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
// 기본 '상태 신호'(비코칭) 모드에서 턴 완료 → 기회를 소모하지 않고 넛지로 대기.
|
|
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
|
|
|
|
const nudge = page.locator(".sx-coach-nudge");
|
|
await expect(nudge).toBeVisible();
|
|
await expect(nudge).toContainText("방금 발화에 코치 제안이 있어요");
|
|
expect(api.liveCoachRequests).toHaveLength(0);
|
|
|
|
await nudge.click();
|
|
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
|
|
await expect(nudge).toHaveCount(0);
|
|
expect(api.liveCoachRequests).toHaveLength(1);
|
|
expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText });
|
|
});
|
|
|
|
// checklist: session-meters-collapse
|
|
// 모바일(≤880px) 압축 레이아웃은 관찰 게이지 패널을 의도적으로 숨긴다(session.css) —
|
|
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.
|
|
test("renders three observation gauges from server progress and collapses to a rapport summary", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name.includes("mobile"), "관찰 게이지 패널은 모바일에서 의도적으로 숨김");
|
|
await routeSessionFixtureApi(page, {
|
|
doneExtra: {
|
|
progress: {
|
|
openness_percent: 46,
|
|
rapport_percent: 44,
|
|
rapport_delta_percent: 4,
|
|
resistance_percent: 52,
|
|
stages: [
|
|
{ stage: "라포", percent: 60, is_goal: true, achieved: false },
|
|
{ stage: "탐색", percent: 10, is_goal: true, achieved: false },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
await startFixtureSession(page);
|
|
|
|
const meters = page.locator(".sx-meters");
|
|
const toggle = meters.locator(".sx-panel-toggle");
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(meters.locator(".sx-meter")).toHaveCount(3);
|
|
await expect(meters.getByRole("progressbar")).toHaveCount(3);
|
|
|
|
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(meters).toContainText("52% · 완화되는 중");
|
|
await expect(meters).toContainText("46%");
|
|
await expect(meters).toContainText("44% · 이번 회기 +4%p");
|
|
|
|
// 접으면 게이지가 사라지고 라포 % 요약만 남는다.
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
await expect(meters.locator(".sx-meter")).toHaveCount(0);
|
|
await expect(toggle).toContainText("라포 44% · 펼치기");
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(meters.locator(".sx-meter")).toHaveCount(3);
|
|
});
|
|
|
|
// checklist: session-voice-skip
|
|
test("shows the voice skip button during TTS playback and returns to idle on skip", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page);
|
|
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
|
|
|
|
await startFixtureSession(page);
|
|
const micButton = page.getByRole("button", { name: "마이크 켜기" });
|
|
await expect(micButton).toBeEnabled();
|
|
await micButton.click();
|
|
await page.getByRole("button", { name: "발화 보내기" }).click();
|
|
|
|
// reply + state:speaking → 재생 중에만 '건너뛰기'가 노출된다.
|
|
await expect(page.locator(".sx-utt").filter({ hasText: voiceClientReply })).toBeVisible();
|
|
const skipButton = page.getByRole("button", { name: "음성 건너뛰기" });
|
|
await expect(skipButton).toBeVisible();
|
|
await expect(page.locator(".sx-mic-block__l")).toHaveText("재생 중");
|
|
const composer = page.getByLabel("학습자 발화 입력");
|
|
await expect(composer).toBeEnabled();
|
|
await composer.fill("음성을 들으면서 다음 질문을 미리 씁니다.");
|
|
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
|
|
|
|
await skipButton.click();
|
|
await expect(skipButton).toHaveCount(0);
|
|
await expect(page.locator(".sx-mic-block__h")).toHaveText(
|
|
"음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.",
|
|
);
|
|
await expect(page.locator(".sx-mic-block__l")).toHaveText("마이크 꺼짐");
|
|
await expect(composer).toHaveValue("음성을 들으면서 다음 질문을 미리 씁니다.");
|
|
});
|
|
|
|
// checklist: session-pause-toggle, session-elapsed-live-region
|
|
test("pauses and resumes session controls and keeps a screen-reader elapsed live region", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
|
|
// sr-only aria-live 경과 시간 리전.
|
|
const liveRegion = page.locator("span.sr-only[aria-live='polite']");
|
|
await expect(liveRegion).toContainText("경과 0:");
|
|
|
|
const compose = page.getByLabel("학습자 발화 입력");
|
|
await page.getByRole("button", { name: "일시정지" }).click();
|
|
|
|
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
|
|
await expect(compose).toBeDisabled();
|
|
await expect(compose).toHaveAttribute("placeholder", "일시정지 중입니다.");
|
|
await expect(page.locator(".sx-mic-block__l")).toHaveText("일시정지");
|
|
await expect(page.getByRole("button", { name: "마이크 켜기" })).toBeDisabled();
|
|
|
|
await page.getByRole("button", { name: "이어가기" }).click();
|
|
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
|
|
await expect(compose).toBeEnabled();
|
|
await expect(compose).toHaveAttribute("placeholder", "학습자 발화를 입력하세요.");
|
|
await expect(page.locator(".sx-mic-block__h")).toHaveText(
|
|
"마이크를 켜면 Chrome 권한 요청 후 음성으로 회기를 진행합니다.",
|
|
);
|
|
});
|
|
|
|
// checklist: session-keyboard-shortcuts
|
|
test("toggles pause with P and mic with Space only outside text inputs", async ({ page }) => {
|
|
await routeSessionFixtureApi(page);
|
|
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
|
|
|
|
await startFixtureSession(page);
|
|
|
|
// P — 일시정지 토글.
|
|
await page.keyboard.press("p");
|
|
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
|
|
await page.keyboard.press("p");
|
|
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
|
|
|
|
// 입력창 포커스 중에는 단축키가 무시되고 글자가 입력된다.
|
|
const compose = page.getByLabel("학습자 발화 입력");
|
|
await compose.click();
|
|
await page.keyboard.press("p");
|
|
await expect(compose).toHaveValue("p");
|
|
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
|
|
|
|
// 포커스를 입력창 밖으로 옮긴 뒤 Space — 마이크 시작.
|
|
await compose.blur();
|
|
await page.keyboard.press("Space");
|
|
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
|
|
});
|
|
});
|