/* ===================================================================== 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; /** POST /auth/consent 응답 상태 (기본 200) */ consentStatus?: number; /** POST /sessions 시작 응답의 회기 시간 계약값 */ durationLimitSeconds?: number; warningBeforeEndSeconds?: number; /** GET /sessions/{id} 재개 스냅샷 덮어쓰기 (status/ended_at/turns 등) */ detailOverrides?: Record; /** 스트림을 위기 안전게이트 응답으로 대체 */ crisisStream?: boolean; /** 스트림 done 이벤트에 병합할 추가 필드 (progress 등) */ doneExtra?: Record; } 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; 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(".sx-signal"); const coach = document.querySelector(".sx-coach-card"); const signal = document.querySelector(".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(".sx-page--active"); const coachBubble = document.querySelector(".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(".sx-page--active"); const bar = document.querySelector(".sx-sessionbar"); const grid = document.querySelector(".sx-grid"); const left = document.querySelector(".sx-col-left"); const center = document.querySelector(".sx-col-center"); const right = document.querySelector(".sx-col-right"); const controls = document.querySelector(".sx-controlbar"); const mic = document.querySelector(".sx-mic"); const micText = document.querySelector(".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(".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(); }); });