유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정
- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59)
This commit is contained in:
parent
4771b97c3a
commit
cf899b7f15
37 changed files with 11989 additions and 67 deletions
422
apps/web/e2e/uc-session-coach-voice.spec.ts
Normal file
422
apps/web/e2e/uc-session-coach-voice.spec.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/* =====================================================================
|
||||
uc-session-coach-voice.spec.ts — AI 튜터 코칭·음성 UI 유스케이스 E2E
|
||||
|
||||
목적: 회기 화면의 AI 코치(피드백 모드·코칭 기회)와 음성 UI(AI 합성 음성 고지·
|
||||
마이크 게이트·Alt+M·텍스트 대안)를 실제 사용자 여정 기준으로 검증한다.
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx —
|
||||
AI_VOICE_DISCLOSURE(100~101, 2782~2785 프리스타트, 3652~3695 컨트롤바),
|
||||
feedbackMode segmented(3712~3739, 기본 "ambient" 432),
|
||||
coach 카드(3390~3483), 마이크 블록(3641~3690),
|
||||
Alt+M 단축키(2158~2163, 입력 중 무시), 음성 가용성 게이트(1195~1196).
|
||||
|
||||
주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음,
|
||||
getUserMedia/마이크 캡처 0회 계약 포함.
|
||||
중복 회피: session-mvp.spec.ts(코칭 정상/열화/quota 갱신·음성 실패 상태),
|
||||
voice.spec.ts(WS 인증 경계), voice-success.spec.ts(캐스케이드 성공 경로).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SESSION_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const DISCLOSURE = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function sseTurnBody(tokens: string[], done: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const token of tokens) lines.push("event: token", `data: ${token}`, "");
|
||||
lines.push("event: done", `data: ${JSON.stringify(done)}`, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
interface CoachVoiceOptions {
|
||||
coachRemaining?: number;
|
||||
coachMax?: number;
|
||||
}
|
||||
|
||||
async function routeCoachVoiceSession(page: Page, options: CoachVoiceOptions = {}) {
|
||||
const { coachRemaining = 3, coachMax = 3 } = options;
|
||||
const startedAt = new Date(Date.now() - 120_000);
|
||||
|
||||
// getUserMedia 호출 계수기 — 어떤 흐름에서도 동의·직접 조작 전 마이크가 열리면 안 된다.
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls = 0;
|
||||
const devices = navigator.mediaDevices;
|
||||
if (devices && devices.getUserMedia) {
|
||||
const original = devices.getUserMedia.bind(devices);
|
||||
devices.getUserMedia = (constraints: MediaStreamConstraints) => {
|
||||
(window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls += 1;
|
||||
return original(constraints);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// catch-all 을 먼저 — 나중 등록이 우선이라 아래 개별 fixture 가 이긴다.
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-0000000ucv01",
|
||||
email: "uc.voice@hs.ac.kr",
|
||||
display_name: "학습자",
|
||||
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: "학습자",
|
||||
self_introduction: "코치·음성 유스케이스 검증용 학습자입니다.",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
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/personas", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "민서(청소년 우울)",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "10대" },
|
||||
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
||||
voice_preset: "soft-young-fem",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// 음성 provider 미설정 — 마이크 게이트/텍스트 대안 경로 검증용.
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: "provider key not configured" })),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-voice-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.34,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [
|
||||
{
|
||||
speaker: "learner",
|
||||
text: "이번 주는 어떻게 지냈는지 이야기해 줄 수 있을까요?",
|
||||
turn_seq: 1,
|
||||
created_at: new Date(startedAt.getTime() + 10_000).toISOString(),
|
||||
},
|
||||
{
|
||||
speaker: "client",
|
||||
text: "그냥 계속 피곤하고, 학교 생각만 하면 답답했어요.",
|
||||
turn_seq: 2,
|
||||
created_at: new Date(startedAt.getTime() + 20_000).toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{ pulse_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", status: "awaiting_agents" },
|
||||
202,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: startedAt.toISOString(),
|
||||
revealed_at: startedAt.toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
source: "database",
|
||||
quota: { remaining: coachRemaining, max: coachMax },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function openSession(page: Page) {
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
}
|
||||
|
||||
function getUserMediaCalls(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __getUserMediaCalls: number }).__getUserMediaCalls ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** 뷰포트에 따라 고지 노출 위치가 달라 "보이는 인스턴스 1개 이상"으로 판정한다. */
|
||||
function visibleDisclosureCount(page: Page) {
|
||||
return page.evaluate((text) => {
|
||||
const nodes = Array.from(document.querySelectorAll("[role=note]"));
|
||||
return nodes.filter(
|
||||
(node) =>
|
||||
node.textContent?.includes(text) &&
|
||||
(node as HTMLElement).offsetParent !== null,
|
||||
).length;
|
||||
}, DISCLOSURE);
|
||||
}
|
||||
|
||||
test.describe("AI 코치·음성 UI — 고지·게이트·피드백 모드", () => {
|
||||
// usecase: 회기를 시작하기 전에 내담자 음성이 AI 합성음이라는 고지를 읽는다
|
||||
test("프리스타트 화면에 AI 합성 음성 고지가 노출된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await page.goto("/learn/session/P1");
|
||||
|
||||
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
|
||||
expect(await visibleDisclosureCount(page)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// usecase: 회기 진행 중에도 AI 합성 음성 고지가 계속 보인다
|
||||
test("활성 회기 화면에 AI 합성 음성 고지가 상시 노출된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
expect(await visibleDisclosureCount(page)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// usecase: 키보드 사용자는 마이크 단축키 안내(Alt+M)를 화면에서 확인할 수 있다
|
||||
test("마이크 단축키 Alt M 안내가 마크업에 존재한다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
await expect(
|
||||
page.locator('[aria-label="마이크 단축키 Alt M"]'),
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
// usecase: 음성 provider 가 준비되지 않았으면 마이크가 비활성화되어 헛클릭이 없다.
|
||||
// 주의: 가용성 사전 게이트(voiceHealth)는 "회기 시작" 직후에만 동작한다
|
||||
// (Session.tsx 1195~1205) — resume 경로는 게이트를 태우지 않으므로
|
||||
// 프리스타트 → 회기 시작 흐름으로 검증한다.
|
||||
test("음성 provider 미가용 시 마이크 버튼이 비활성화된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-voice-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.34,
|
||||
goal_stages: ["라포", "탐색"],
|
||||
duration_limit_seconds: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
degraded: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
|
||||
// 게이트가 실제로 동작했음을 안내 문구로 확증한 뒤 비활성화를 판정한다.
|
||||
await expect(page.locator(".sx-mic-block__h")).toContainText(
|
||||
"음성 기능이 설정되지 않았습니다",
|
||||
);
|
||||
const mic = page.locator(".sx-mic");
|
||||
await expect(mic).toBeDisabled();
|
||||
await expect(mic).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
// usecase: 회기 화면을 열었다는 이유만으로 마이크 권한이 요청되면 안 된다
|
||||
test("회기 진입만으로는 getUserMedia 가 호출되지 않는다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
expect(await getUserMediaCalls(page)).toBe(0);
|
||||
});
|
||||
|
||||
// usecase: 입력창에 글을 쓰는 중의 Alt+M 은 마이크 토글로 오작동하지 않는다
|
||||
test("입력창 포커스 중 Alt+M 은 무시되고 마이크는 열리지 않는다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
const composer = page.getByLabel("학습자 발화 입력");
|
||||
await composer.click();
|
||||
await composer.fill("마이크 단축키 문자를 입력 중입니다 m");
|
||||
await page.keyboard.press("Alt+m");
|
||||
|
||||
expect(await getUserMediaCalls(page)).toBe(0);
|
||||
await expect(page.locator(".sx-mic")).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
// usecase: 음성이 안 되는 환경에서도 텍스트로 회기를 계속할 수 있다
|
||||
test("음성 미가용 상태에서도 텍스트 턴이 정상 완료된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: sseTurnBody(["말해 줘서 ", "고마워요."], {
|
||||
session_id: SESSION_ID,
|
||||
stage: "라포",
|
||||
effective_openness: 0.4,
|
||||
turn_seq: 3,
|
||||
safety_flagged: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await openSession(page);
|
||||
|
||||
const composer = page.getByLabel("학습자 발화 입력");
|
||||
await composer.fill("그 답답함이 몸에서는 어떻게 느껴졌는지 궁금해요.");
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator(".sx-utt.is-client").filter({ hasText: "말해 줘서 고마워요." }),
|
||||
).toBeVisible();
|
||||
await expect(composer).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 코칭 모드를 켜면 코치 카드가 기본 안내와 함께 나타난다
|
||||
test("코칭 모드 전환 시 코치 카드와 기본 안내가 나타난다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: /^코칭/ }).click();
|
||||
await expect(page.locator(".sx-coach-card")).toBeVisible();
|
||||
await expect(page.locator(".sx-coach-card")).toContainText("AI 코치");
|
||||
await expect(
|
||||
page.getByText("코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 남은 코칭 기회가 숫자와 도트로 함께 표시된다
|
||||
test("코칭 기회 잔여 수가 도트·숫자·aria 라벨로 표기된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page, { coachRemaining: 2, coachMax: 3 });
|
||||
await openSession(page);
|
||||
|
||||
const coachedToggle = page.getByRole("button", { name: "코칭 모드, 남은 기회 2개" });
|
||||
await expect(coachedToggle).toBeVisible();
|
||||
await coachedToggle.click();
|
||||
|
||||
const quota = page.locator(".sx-coach-quota");
|
||||
await expect(quota).toContainText("코칭 기회");
|
||||
await expect(quota).toContainText("2/3");
|
||||
await expect(quota.locator(".sx-coach-quota__dots i.is-filled")).toHaveCount(2);
|
||||
});
|
||||
|
||||
// usecase: 코칭 기회를 다 쓰면 충전 조건이 안내된다
|
||||
test("코칭 기회 소진 시 충전 조건 안내가 뜬다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page, { coachRemaining: 0, coachMax: 3 });
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: "코칭 모드, 남은 기회 0개" }).click();
|
||||
await expect(
|
||||
page.getByText("코칭 기회를 모두 사용했습니다", { exact: false }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".sx-segmented__badge.is-empty")).toBeAttached();
|
||||
});
|
||||
|
||||
// usecase: 몰입 모드를 켜면 실시간 신호 패널이 조용해진다.
|
||||
// 주의: ≤1180px 에서는 우측 진단 레일 전체가 숨겨지는 것이 계약이다
|
||||
// (session.css 3407~3409) — 몰입 안내 문구는 데스크톱에서만 보인다.
|
||||
test("몰입 모드에서는 신호 패널 대신 몰입 안내가 뜬다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: "몰입", exact: true }).click();
|
||||
await expect(page.locator(".sx-page--active.sx-feedback-immersive")).toBeVisible();
|
||||
await expect(page.locator(".sx-coach-card")).toHaveCount(0);
|
||||
|
||||
const immersiveNote = page.getByText("몰입 모드", { exact: true });
|
||||
if ((page.viewportSize()?.width ?? 0) > 1180) {
|
||||
await expect(immersiveNote).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("실시간 신호 없이 대화에만 집중합니다", { exact: false }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(immersiveNote).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 상태 신호 모드는 신호를 "조용히 표시"로 유지한다
|
||||
test("상태 신호 모드에서 조용한 신호 라벨이 표시된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: "상태 신호" }).click();
|
||||
await expect(page.getByText("조용히 표시").first()).toBeAttached();
|
||||
});
|
||||
|
||||
// usecase: 피드백 모드 그룹이 접근 가능한 이름을 갖는다
|
||||
test("피드백 모드 segmented 그룹이 role=group 으로 노출된다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
const group = page.getByRole("group", { name: "피드백 모드" });
|
||||
await expect(group).toBeVisible();
|
||||
await expect(group.getByRole("button")).toHaveCount(3);
|
||||
});
|
||||
|
||||
// usecase: 마이크 버튼은 상태를 설명하는 접근 가능한 이름을 갖는다
|
||||
test("마이크 버튼이 aria-pressed 와 설명 라벨을 갖는다", async ({ page }) => {
|
||||
await routeCoachVoiceSession(page);
|
||||
await openSession(page);
|
||||
|
||||
const mic = page.locator(".sx-mic");
|
||||
await expect(mic).toHaveAttribute("aria-pressed", "false");
|
||||
const label = await mic.getAttribute("aria-label");
|
||||
expect(label ?? "").not.toBe("");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue