vignette/apps/web/e2e/full-sweep-session.spec.ts
2026-08-09 22:00:32 +09:00

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();
});
});