1204 lines
46 KiB
TypeScript
1204 lines
46 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}/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-000000000778",
|
|
consent_status: "granted",
|
|
deletion_request_id: null,
|
|
idempotent_replay: false,
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.route(`**/api/sessions/${fixtureSessionId}/alliance-pulses`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
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/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 };
|
|
}
|
|
|
|
async function acceptVoiceInputConsent(page: Page) {
|
|
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
|
|
await expect(consentDialog).toBeVisible();
|
|
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
|
|
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
|
|
await expect(consentDialog).toHaveCount(0);
|
|
}
|
|
|
|
/**
|
|
* 음성 마이크·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", "종료된 회기입니다.");
|
|
});
|
|
|
|
test("naturalizes privacy placeholders when a persisted session is resumed", async ({
|
|
page,
|
|
}) => {
|
|
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
|
await routeSessionFixtureApi(page, {
|
|
detailOverrides: {
|
|
started_at: startedAt,
|
|
turns: [
|
|
{
|
|
speaker: "learner",
|
|
text:
|
|
"[NAME] 이야기와 [PHONE]·[EMAIL]·[RRN]·[NUMID]·[DATE]·[MONEY]·[ADDR]·[ADDRESS]를 확인할까요?",
|
|
turn_seq: 1,
|
|
created_at: new Date(Date.now() - 50_000).toISOString(),
|
|
},
|
|
{
|
|
speaker: "client",
|
|
text: "여기서 뭘 할 수 있게 [NAME]는 건지 잘 [NAME]는데요. [ORG]에서 오라고 했어요.",
|
|
turn_seq: 2,
|
|
created_at: new Date(Date.now() - 40_000).toISOString(),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await page.goto(`/learn/session/${fixtureSessionId}`);
|
|
|
|
const transcript = page.locator(".sx-transcript");
|
|
await expect(transcript.locator(".sx-utt")).toHaveCount(2);
|
|
for (const token of [
|
|
"[NAME]",
|
|
"[ORG]",
|
|
"[PHONE]",
|
|
"[EMAIL]",
|
|
"[RRN]",
|
|
"[NUMID]",
|
|
"[DATE]",
|
|
"[MONEY]",
|
|
"[ADDR]",
|
|
"[ADDRESS]",
|
|
]) {
|
|
await expect(transcript).not.toContainText(token);
|
|
}
|
|
await expect(transcript).toContainText("익명 내담자 이야기");
|
|
await expect(transcript).toContainText(
|
|
"뭘 할 수 있게 되는 건지 잘 모르겠는데요",
|
|
);
|
|
await expect(transcript).toContainText("소속 기관에서 오라고 했어요");
|
|
});
|
|
|
|
test("caps an overdue active session honestly and preserves 44px mobile controls", async ({
|
|
page,
|
|
}) => {
|
|
await routeSessionFixtureApi(page, {
|
|
durationLimitSeconds: 3600,
|
|
detailOverrides: {
|
|
started_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
},
|
|
});
|
|
|
|
await page.goto(`/learn/session/${fixtureSessionId}`);
|
|
await expect(page.locator("#sx-end-dialog-title")).toHaveText(
|
|
"회기 시간이 끝났어요",
|
|
);
|
|
await expect(page.locator(".sx-sessionbar__meta")).toContainText("시간 만료");
|
|
await expect(page.locator("span.sr-only[aria-live='polite']")).toHaveText(
|
|
"회기 시간 만료",
|
|
);
|
|
|
|
for (const viewport of [
|
|
{ width: 390, height: 844 },
|
|
{ width: 320, height: 568 },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
const targetMetrics = await page
|
|
.locator(".sx-page--active button:visible, .sx-page--active textarea:visible")
|
|
.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
label:
|
|
element.getAttribute("aria-label") ||
|
|
element.textContent?.replace(/\s+/g, " ").trim() ||
|
|
element.tagName,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}),
|
|
);
|
|
expect(targetMetrics.length, `${viewport.width} visible controls`).toBeGreaterThan(0);
|
|
for (const target of targetMetrics) {
|
|
expect(
|
|
target.width,
|
|
`${viewport.width} ${target.label} touch width`,
|
|
).toBeGreaterThanOrEqual(44);
|
|
expect(
|
|
target.height,
|
|
`${viewport.width} ${target.label} touch height`,
|
|
).toBeGreaterThanOrEqual(44);
|
|
}
|
|
const layout = await page.evaluate(() => ({
|
|
viewportWidth: window.innerWidth,
|
|
documentWidth: document.documentElement.scrollWidth,
|
|
bodyText: document.body.innerText,
|
|
}));
|
|
expect(layout.documentWidth, `${viewport.width} horizontal overflow`).toBeLessThanOrEqual(
|
|
layout.viewportWidth,
|
|
);
|
|
expect(layout.bodyText).not.toMatch(/\b\d{4,}:\d{2}\b/);
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// 최신 발화를 따라가는 동안 scrollport가 모바일 레이아웃으로 재배치돼도
|
|
// ResizeObserver가 새 하단에 붙이고 layout scroll을 사용자 이탈로 오인하지 않는다.
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect
|
|
.poll(() =>
|
|
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
|
)
|
|
.toBeLessThan(24);
|
|
await expect(jumpButton).toHaveCount(0);
|
|
|
|
// 위로 스크롤 → 자동 따라가기 해제 + '최신으로' 복귀 버튼 노출.
|
|
await scroller.hover();
|
|
await page.mouse.wheel(0, -10_000);
|
|
await expect(jumpButton).toBeVisible();
|
|
await page.setViewportSize({ width: 320, height: 568 });
|
|
await expect(jumpButton).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
|
)
|
|
.toBeGreaterThanOrEqual(24);
|
|
|
|
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")).toHaveCount(0);
|
|
await expect(page.locator(".sx-coach-card")).toBeVisible();
|
|
|
|
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,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1366, height: 640 });
|
|
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();
|
|
const coachCard = page.locator(".sx-coach-card");
|
|
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
|
|
await expect(page.locator(".sx-signal__wave")).toHaveCount(0);
|
|
|
|
const railLayout = await page.evaluate(() => {
|
|
const panel = document.querySelector<HTMLElement>(".sx-signal");
|
|
const coach = document.querySelector<HTMLElement>(".sx-coach-card");
|
|
const signal = document.querySelector<HTMLElement>(".sx-signal__one");
|
|
if (!panel || !coach || !signal) return null;
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const coachRect = coach.getBoundingClientRect();
|
|
const signalRect = signal.getBoundingClientRect();
|
|
return {
|
|
coachBeforeSignal: coachRect.top < signalRect.top,
|
|
coachInsidePanel:
|
|
coachRect.top >= panelRect.top - 1 && coachRect.bottom <= panelRect.bottom + 1,
|
|
coachInsideViewport: coachRect.bottom <= window.innerHeight + 1,
|
|
};
|
|
});
|
|
expect(railLayout).toEqual({
|
|
coachBeforeSignal: true,
|
|
coachInsidePanel: true,
|
|
coachInsideViewport: true,
|
|
});
|
|
|
|
await page.setViewportSize({ width: 1024, height: 640 });
|
|
await expect(coachCard).toBeVisible();
|
|
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
|
|
await expect(page.locator(".sx-signal__one")).toBeHidden();
|
|
const compactCoachInsideViewport = await coachCard.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.top >= -1 && rect.bottom <= window.innerHeight + 1;
|
|
});
|
|
expect(compactCoachInsideViewport).toBe(true);
|
|
|
|
await expect(nudge).toHaveCount(0);
|
|
expect(api.liveCoachRequests).toHaveLength(1);
|
|
expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText });
|
|
});
|
|
|
|
test("uses the global light theme tokens throughout an active session", async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("vignette.theme", "light");
|
|
});
|
|
await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
|
await page.getByRole("button", { name: /코칭 모드, 남은 기회/ }).click();
|
|
await expect(page.locator(".sx-coach-bubble")).toBeVisible();
|
|
|
|
const themeState = await page.evaluate(() => {
|
|
const rootStyle = getComputedStyle(document.documentElement);
|
|
const session = document.querySelector<HTMLElement>(".sx-page--active");
|
|
const coachBubble = document.querySelector<HTMLElement>(".sx-coach-bubble");
|
|
if (!session || !coachBubble) return null;
|
|
const sessionStyle = getComputedStyle(session);
|
|
const coachStyle = getComputedStyle(coachBubble);
|
|
return {
|
|
rootSurface: rootStyle.getPropertyValue("--bg-surface").trim(),
|
|
sessionSurface: sessionStyle.getPropertyValue("--bg-surface").trim(),
|
|
rootText: rootStyle.getPropertyValue("--text-strong").trim(),
|
|
sessionText: sessionStyle.getPropertyValue("--text-strong").trim(),
|
|
colorScheme: sessionStyle.colorScheme,
|
|
coachInsetSurface:
|
|
coachBubble.classList.contains("vg-surface--inset") &&
|
|
coachStyle.getPropertyValue("--glass-surface-inset").trim() ===
|
|
rootStyle.getPropertyValue("--glass-surface-inset").trim(),
|
|
};
|
|
});
|
|
|
|
expect(themeState).not.toBeNull();
|
|
expect(themeState!.sessionSurface).toBe(themeState!.rootSurface);
|
|
expect(themeState!.sessionText).toBe(themeState!.rootText);
|
|
expect(themeState!.colorScheme).toBe("light");
|
|
expect(themeState!.coachInsetSurface).toBe(true);
|
|
});
|
|
|
|
test("matches the 1536px botanical session workspace composition", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1536, height: 1024 });
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("vignette.theme", "light");
|
|
});
|
|
await routeSessionFixtureApi(page);
|
|
|
|
await startFixtureSession(page);
|
|
await expect(page.getByText("라이브 코칭", { exact: true })).toBeVisible();
|
|
await expect(page.locator(".sx-coach-card")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "상태 신호" })).toHaveClass(/is-on/);
|
|
|
|
const layout = await page.evaluate(async () => {
|
|
const pageRoot = document.querySelector<HTMLElement>(".sx-page--active");
|
|
const bar = document.querySelector<HTMLElement>(".sx-sessionbar");
|
|
const grid = document.querySelector<HTMLElement>(".sx-grid");
|
|
const left = document.querySelector<HTMLElement>(".sx-col-left");
|
|
const center = document.querySelector<HTMLElement>(".sx-col-center");
|
|
const right = document.querySelector<HTMLElement>(".sx-col-right");
|
|
const controls = document.querySelector<HTMLElement>(".sx-controlbar");
|
|
const mic = document.querySelector<HTMLElement>(".sx-mic");
|
|
const micText = document.querySelector<HTMLElement>(".sx-mic-block__ms");
|
|
if (!pageRoot || !bar || !grid || !left || !center || !right || !controls || !mic || !micText) {
|
|
return null;
|
|
}
|
|
const rect = (element: HTMLElement) => {
|
|
const value = element.getBoundingClientRect();
|
|
return {
|
|
left: Math.round(value.left),
|
|
top: Math.round(value.top),
|
|
right: Math.round(value.right),
|
|
bottom: Math.round(value.bottom),
|
|
width: Math.round(value.width),
|
|
height: Math.round(value.height),
|
|
};
|
|
};
|
|
const leafResponse = await fetch("/session-botanical/leaf-1.webp");
|
|
return {
|
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
page: rect(pageRoot),
|
|
bar: rect(bar),
|
|
grid: rect(grid),
|
|
left: rect(left),
|
|
center: rect(center),
|
|
right: rect(right),
|
|
controls: rect(controls),
|
|
mic: rect(mic),
|
|
micText: rect(micText),
|
|
pageLeaf: getComputedStyle(pageRoot, "::before").backgroundImage,
|
|
stageLeaf: getComputedStyle(
|
|
document.querySelector<HTMLElement>(".sx-orb-wrap")!,
|
|
"::before",
|
|
).backgroundImage,
|
|
leafAsset: {
|
|
ok: leafResponse.ok,
|
|
contentType: leafResponse.headers.get("content-type"),
|
|
},
|
|
};
|
|
});
|
|
|
|
expect(layout).not.toBeNull();
|
|
expect(layout!.viewport).toEqual({ width: 1536, height: 1024 });
|
|
expect(layout!.page).toMatchObject({ left: 0, top: 0, width: 1536, height: 1024 });
|
|
expect(layout!.bar.width).toBeGreaterThanOrEqual(1400);
|
|
expect(layout!.bar.width).toBeLessThanOrEqual(1410);
|
|
expect(layout!.grid.width).toBeGreaterThanOrEqual(1400);
|
|
expect(layout!.grid.width).toBeLessThanOrEqual(1410);
|
|
expect(layout!.left.width).toBeGreaterThanOrEqual(324);
|
|
expect(layout!.left.width).toBeLessThanOrEqual(328);
|
|
expect(layout!.right.width).toBeGreaterThanOrEqual(355);
|
|
expect(layout!.right.width).toBeLessThanOrEqual(359);
|
|
expect(layout!.center.left).toBeGreaterThan(layout!.left.right);
|
|
expect(layout!.right.left).toBeGreaterThan(layout!.center.right);
|
|
expect(layout!.controls.width).toBeGreaterThanOrEqual(1460);
|
|
expect(layout!.controls.width).toBeLessThanOrEqual(1470);
|
|
expect(layout!.controls.bottom).toBeLessThanOrEqual(1024);
|
|
expect(layout!.micText.left).toBeGreaterThanOrEqual(layout!.mic.right + 8);
|
|
expect(layout!.pageLeaf).toContain("leaf-5.webp");
|
|
expect(layout!.stageLeaf).toContain("leaf-1.webp");
|
|
expect(layout!.leafAsset).toEqual({ ok: true, contentType: "image/webp" });
|
|
|
|
const screenshotPath = testInfo.outputPath("session-botanical-1536x1024.png");
|
|
await page.screenshot({ path: screenshotPath, animations: "disabled" });
|
|
await testInfo.attach("session-botanical-1536x1024", {
|
|
path: screenshotPath,
|
|
contentType: "image/png",
|
|
});
|
|
});
|
|
|
|
// 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 acceptVoiceInputConsent(page);
|
|
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 Alt+M only outside text inputs", async ({ page }) => {
|
|
await routeSessionFixtureApi(page);
|
|
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
|
|
|
|
await startFixtureSession(page);
|
|
await page.evaluate(() => {
|
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
|
});
|
|
|
|
// 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();
|
|
|
|
// 포커스를 입력창 밖으로 옮긴 뒤 Alt+M — 마이크 시작.
|
|
await compose.blur();
|
|
await page.keyboard.press("Alt+m");
|
|
await acceptVoiceInputConsent(page);
|
|
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
|
|
});
|
|
});
|