유스케이스 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
|
|
@ -175,7 +175,10 @@ function widthsFor(breakpoints: number[]): number[] {
|
|||
if (merged.length > MAX_WIDTHS_PER_ROUTE) {
|
||||
// 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭),
|
||||
// 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다.
|
||||
const keep = new Set<number>(devices);
|
||||
// keep 은 "경계에 이미 있다고 제외된 devices" 가 아니라 전체 DEVICE_WIDTHS 로
|
||||
// 시드한다. 아니면 브레이크포인트와 겹치는 실기기 폭(예: 360)이 솎아내기에서
|
||||
// 빠져 arrayContaining(DEVICE_WIDTHS) 계약이 깨진다.
|
||||
const keep = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
|
||||
const budget = MAX_WIDTHS_PER_ROUTE - keep.size;
|
||||
const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget)));
|
||||
for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]);
|
||||
|
|
@ -640,6 +643,15 @@ function widthsForPage(page: keyof typeof PAGE_CSS) {
|
|||
return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]);
|
||||
}
|
||||
|
||||
/** 해당 페이지가 폭 상한을 넘어 솎아내기가 일어났는지 — 3연폭 계약의 예외 조건. */
|
||||
function isPrunedPage(page: keyof typeof PAGE_CSS) {
|
||||
const candidate = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
|
||||
for (const bp of [...new Set([...index.common, ...(index.byPage[page] ?? [])])]) {
|
||||
for (const delta of [-1, 0, 1]) candidate.add(clampWidth(bp + delta));
|
||||
}
|
||||
return candidate.size > MAX_WIDTHS_PER_ROUTE;
|
||||
}
|
||||
|
||||
async function runSweep(page: Page, routes: RouteCase[]) {
|
||||
const report: string[] = [];
|
||||
let total = 0;
|
||||
|
|
@ -674,7 +686,7 @@ test.describe("브레이크포인트 경계 스윕 @single-run", () => {
|
|||
// 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외).
|
||||
for (const bp of index.byPage[pageKey] ?? []) {
|
||||
if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue;
|
||||
if (widths.length >= MAX_WIDTHS_PER_ROUTE) continue; // 상한으로 솎아 낸 경우는 예외
|
||||
if (isPrunedPage(pageKey)) continue; // 상한으로 솎아 낸 경우는 예외
|
||||
expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual(
|
||||
expect.arrayContaining([bp - 1, bp, bp + 1]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ interface AdminUsageBreakdown {
|
|||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
recorded_cost_usd: number;
|
||||
estimated_cost_usd: number;
|
||||
token_metered_turns: number;
|
||||
token_unmetered_turns: number;
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable";
|
||||
rate_label?: string | null;
|
||||
rate_source_url?: string | null;
|
||||
}
|
||||
|
||||
interface AdminUsageDailyCost {
|
||||
|
|
@ -38,9 +45,13 @@ interface AdminUsageResponse {
|
|||
window_days: number;
|
||||
total_turns: number;
|
||||
metered_turns: number;
|
||||
token_metered_turns: number;
|
||||
token_unmetered_turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
recorded_cost_usd: number;
|
||||
estimated_cost_usd: number;
|
||||
budget: {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
|
|
@ -74,9 +85,14 @@ function usageFixture(
|
|||
window_days: days,
|
||||
total_turns: 0,
|
||||
metered_turns: 0,
|
||||
token_metered_turns: 0,
|
||||
token_unmetered_turns: 0,
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
cost_usd: 0,
|
||||
// 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다.
|
||||
recorded_cost_usd: overrides.cost_usd ?? 0,
|
||||
estimated_cost_usd: 0,
|
||||
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
|
||||
evaluator_cache: {
|
||||
enabled: false,
|
||||
|
|
@ -381,7 +397,8 @@ test.describe("full sweep: admin ai operations", () => {
|
|||
await page.getByRole("button", { name: "7일" }).click();
|
||||
await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(ledger).toContainText("$1.11");
|
||||
await expect(ledger).toContainText("7일 DB 집계");
|
||||
// 재설계된 원장 카드: 데이터 로드 후에는 "N일 DB 집계" 대신 기록·참조 분해를 표시한다.
|
||||
await expect(ledger).toContainText("기록 $1.11 · 참조 $0");
|
||||
|
||||
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
|
||||
slowWindow.resolve();
|
||||
|
|
@ -405,6 +422,8 @@ test.describe("full sweep: admin ai operations", () => {
|
|||
usageFixture(days, {
|
||||
total_turns: 40,
|
||||
metered_turns: 30,
|
||||
token_metered_turns: 24,
|
||||
token_unmetered_turns: 6,
|
||||
tokens_in: 125_000,
|
||||
tokens_out: 18_500,
|
||||
cost_usd: 6.6212,
|
||||
|
|
@ -424,9 +443,15 @@ test.describe("full sweep: admin ai operations", () => {
|
|||
provider: "openai",
|
||||
model: "gpt-5-mini",
|
||||
turns: 30,
|
||||
token_metered_turns: 24,
|
||||
token_unmetered_turns: 6,
|
||||
tokens_in: 125_000,
|
||||
tokens_out: 18_500,
|
||||
cost_usd: 6.6212,
|
||||
recorded_cost_usd: 6.6212,
|
||||
estimated_cost_usd: 0,
|
||||
cost_basis: "provider_reported",
|
||||
rate_label: null,
|
||||
},
|
||||
],
|
||||
daily_cost: [
|
||||
|
|
@ -440,14 +465,15 @@ test.describe("full sweep: admin ai operations", () => {
|
|||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
// DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내.
|
||||
// 토큰 계량 커버리지 트랙: token_metered/metered 비율과 토큰 미수집·DB 원장 미연결 안내.
|
||||
const budgetPanel = page.locator(".aic-budget");
|
||||
await expect(budgetPanel).toContainText("DB 계량 커버리지");
|
||||
await expect(budgetPanel).toContainText("75%");
|
||||
await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1);
|
||||
await expect(budgetPanel).toContainText("토큰 계량 커버리지");
|
||||
await expect(budgetPanel).toContainText("80%");
|
||||
await expect(budgetPanel.locator('[aria-label="토큰 계량 커버리지 80%"]')).toHaveCount(1);
|
||||
await expect(budgetPanel).toContainText(
|
||||
"10개 턴은 provider·model·token·cost 계량 필드가 없습니다.",
|
||||
"6개 원장 호출은 과거 토큰 미수집 건이라 복원 없이 미계량으로 남깁니다.",
|
||||
);
|
||||
await expect(budgetPanel).toContainText("DB 원장 미연결 10턴");
|
||||
|
||||
// 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수.
|
||||
const chart = page.locator(".aic-chart[role='img']");
|
||||
|
|
|
|||
|
|
@ -245,19 +245,26 @@ test.describe("full-sweep session review", () => {
|
|||
const filters = page.locator(".sr-tx__filters");
|
||||
const allChip = filters.getByRole("button", { name: "전체" });
|
||||
const mineChip = filters.getByRole("button", { name: "내 발화만" });
|
||||
// 동맹 펄스 카드(AlliancePulseCard)의 근거 장면 선택기가 같은 발화를 인용하므로
|
||||
// 발화 본문 확인은 축어록 탭패널 안으로 한정한다(strict mode 중복 방지).
|
||||
const transcript = page.locator("#sr-panel-transcript");
|
||||
|
||||
await expect(allChip).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(mineChip).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator(".sr-turn")).toHaveCount(6);
|
||||
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeVisible();
|
||||
await expect(
|
||||
transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }),
|
||||
).toBeVisible();
|
||||
|
||||
await mineChip.click();
|
||||
await expect(mineChip).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(allChip).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator(".sr-turn")).toHaveCount(3);
|
||||
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }),
|
||||
transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
transcript.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }),
|
||||
).toBeVisible();
|
||||
|
||||
// 축어록이 없으면 두 필터 칩 모두 비활성화된다.
|
||||
|
|
|
|||
|
|
@ -119,35 +119,21 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
|||
// checklist: shell-appshell-role-theming
|
||||
// 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다.
|
||||
test("applies body[data-role] theming for each role shell", async ({ page }) => {
|
||||
let currentUser = meResponse();
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(currentUser),
|
||||
});
|
||||
});
|
||||
|
||||
// /auth/me만 fixture로 고정하면 나머지 실 API 호출(/api/sessions 등)이 쿠키 없이
|
||||
// 401을 돌려주고, api 클라이언트의 AUTH_EXPIRED 이벤트가 AuthProvider의 user를
|
||||
// 비워 body[data-role]을 제거한다(실제 세션 만료 계약). 역할 테마는 역할별 실
|
||||
// dev-login 세션으로 검증한다.
|
||||
await signInAsLearner(page);
|
||||
await page.goto("/learn");
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
|
||||
|
||||
currentUser = meResponse({
|
||||
role: "teacher",
|
||||
email: "shell.teacher@hs.ac.kr",
|
||||
display_name: "Shell Teacher",
|
||||
});
|
||||
await signInAsTeacher(page);
|
||||
await page.goto("/teach");
|
||||
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "instructor");
|
||||
|
||||
currentUser = meResponse({
|
||||
role: "admin",
|
||||
email: "shell.admin@twentyoz.kr",
|
||||
display_name: "Shell Admin",
|
||||
admin_access: true,
|
||||
super_admin: true,
|
||||
});
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { expect, test, type APIResponse, type Page } from "@playwright/test";
|
||||
import { completeOnboarding, expectNoHorizontalOverflow } from "./support";
|
||||
import {
|
||||
completeOnboarding,
|
||||
expectNoHorizontalOverflow,
|
||||
withGlobalEngineConfigLock,
|
||||
} from "./support";
|
||||
|
||||
async function expectResponseOk(response: APIResponse) {
|
||||
if (!response.ok()) {
|
||||
|
|
@ -74,6 +78,7 @@ test.describe("production readiness gates", () => {
|
|||
});
|
||||
|
||||
test("admin-owned runtime controls are durable database state", async ({ page }) => {
|
||||
test.setTimeout(60_000); // 엔진 설정 전역 락 대기 여유 포함
|
||||
await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin");
|
||||
|
||||
const usersResponse = await page.request.get("/api/admin/users");
|
||||
|
|
@ -81,21 +86,56 @@ test.describe("production readiness gates", () => {
|
|||
const users = (await usersResponse.json()) as { source: string; durable: boolean };
|
||||
expect(users).toMatchObject({ source: "database", durable: true });
|
||||
|
||||
const engineResponse = await page.request.get("/api/admin/engine-config");
|
||||
await expectResponseOk(engineResponse);
|
||||
const engine = (await engineResponse.json()) as {
|
||||
durable: boolean;
|
||||
source: string;
|
||||
engine_mode: string;
|
||||
engine_url: string;
|
||||
model: string;
|
||||
reasoning_effort: string | null;
|
||||
};
|
||||
expect(engine).toMatchObject({ durable: true, source: "database" });
|
||||
expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain(
|
||||
engine.engine_mode,
|
||||
);
|
||||
expect(engine.engine_url).toMatch(/^https?:\/\//);
|
||||
expect(engine.model.trim().length).toBeGreaterThan(0);
|
||||
// 엔진 설정의 durable 계약은 "관리자가 저장한 설정이 DB 행으로 남는다"이다.
|
||||
// 시드 없는 격리 DB에서는 저장 전 GET이 runtime_default(durable=false)를
|
||||
// 돌려주는 것이 정상 계약이므로, 실제 게이트웨이 검증을 거친 저장 round-trip
|
||||
// 후의 durable/database 상태를 확인한다. (support.ts withGlobalEngineConfigLock 계약)
|
||||
await withGlobalEngineConfigLock("readiness-admin-engine", async () => {
|
||||
type EngineConfig = {
|
||||
durable: boolean;
|
||||
source: string;
|
||||
engine_mode: string;
|
||||
engine_url: string;
|
||||
model: string;
|
||||
reasoning_effort: string | null;
|
||||
};
|
||||
const currentResponse = await page.request.get("/api/admin/engine-config");
|
||||
await expectResponseOk(currentResponse);
|
||||
const current = (await currentResponse.json()) as EngineConfig;
|
||||
|
||||
const capabilityResponse = await page.request.get(
|
||||
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(current.engine_mode)}`,
|
||||
);
|
||||
await expectResponseOk(capabilityResponse);
|
||||
const capability = (await capabilityResponse.json()) as {
|
||||
models: Array<{ id: string; default_reasoning_effort?: string | null }>;
|
||||
};
|
||||
const selected =
|
||||
capability.models.find((model) => model.id === current.model) ?? capability.models[0];
|
||||
if (!selected) throw new Error("engine capability returned no selectable model");
|
||||
|
||||
const patchResponse = await page.request.patch("/api/admin/engine-config", {
|
||||
data: {
|
||||
engine_mode: current.engine_mode,
|
||||
engine_url: current.engine_url,
|
||||
model: selected.id,
|
||||
reasoning_effort:
|
||||
selected.id === current.model
|
||||
? current.reasoning_effort
|
||||
: (selected.default_reasoning_effort ?? null),
|
||||
},
|
||||
});
|
||||
await expectResponseOk(patchResponse);
|
||||
|
||||
const engineResponse = await page.request.get("/api/admin/engine-config");
|
||||
await expectResponseOk(engineResponse);
|
||||
const engine = (await engineResponse.json()) as EngineConfig;
|
||||
expect(engine).toMatchObject({ durable: true, source: "database" });
|
||||
expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain(
|
||||
engine.engine_mode,
|
||||
);
|
||||
expect(engine.engine_url).toMatch(/^https?:\/\//);
|
||||
expect(engine.model.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,6 +70,21 @@ async function createEndedLearnerSession(page: Page) {
|
|||
});
|
||||
await expectResponseOk(start);
|
||||
const session = (await start.json()) as SessionStartResponse;
|
||||
// 0턴 종료 회기는 서버 계약상 검토 대상이 아니다(teacher.py _needs_teacher_review:
|
||||
// turn_count > 0 또는 평가 ready/error). 검토 큐에 실제로 잡히는 종료 회기를 만들기
|
||||
// 위해 실 엔진으로 학습자 발화 1턴을 기록한 뒤 종료한다.
|
||||
let turn = await page.request.post(`/api/sessions/${session.session_id}/turn`, {
|
||||
data: { text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2 && !turn.ok(); attempt += 1) {
|
||||
await page.waitForTimeout(1000);
|
||||
turn = await page.request.post(`/api/sessions/${session.session_id}/turn`, {
|
||||
data: { text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
await expectResponseOk(turn);
|
||||
const ended = await page.request.post(`/api/sessions/${session.session_id}/end`);
|
||||
await expectResponseOk(ended);
|
||||
return session.session_id;
|
||||
|
|
@ -599,6 +614,7 @@ test.describe("teacher console", () => {
|
|||
});
|
||||
|
||||
test("renders real server sessions from server-owned rows", async ({ page }) => {
|
||||
test.setTimeout(120_000); // 실 엔진 1턴 생성 포함
|
||||
const sessionId = await createEndedLearnerSession(page);
|
||||
await signInAsTeacher(page);
|
||||
|
||||
|
|
@ -1208,6 +1224,7 @@ test.describe("teacher console", () => {
|
|||
});
|
||||
|
||||
test("keeps long teacher lists in bounded panels", async ({ page }) => {
|
||||
test.setTimeout(120_000); // 실 엔진 1턴 생성 포함
|
||||
await createEndedLearnerSession(page);
|
||||
await signInAsTeacher(page);
|
||||
|
||||
|
|
|
|||
1135
apps/web/e2e/uc-a11y-mobile.spec.ts
Normal file
1135
apps/web/e2e/uc-a11y-mobile.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
1044
apps/web/e2e/uc-admin-console.spec.ts
Normal file
1044
apps/web/e2e/uc-admin-console.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
416
apps/web/e2e/uc-auth-onboarding.spec.ts
Normal file
416
apps/web/e2e/uc-auth-onboarding.spec.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
/* =====================================================================
|
||||
uc-auth-onboarding.spec.ts — 로그인·온보딩·승인대기·라우트 가드 유스케이스.
|
||||
|
||||
목적: 실제 사용자가 겪는 인증 여정(로그인 화면 확인 → 딥링크 가드 →
|
||||
온보딩 입력 검증 → 승인 대기 → 역할별 홈 진입 → 세션 복원)을
|
||||
route fixture만으로 검증한다. 실 API·dev-login 헬퍼를 쓰지 않으며,
|
||||
실제 AI 엔진 턴 생성은 없다.
|
||||
|
||||
근거:
|
||||
- src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx
|
||||
(허용 도메인 배지, 비치료 고지 문구, OAuth 준비 상태)
|
||||
- src/pages/Onboarding.tsx (canSubmit 필수 입력·동의 검증, isValidPhone,
|
||||
약관 details, source_note)
|
||||
- src/pages/PendingApproval.tsx (승인 상태 새로고침, 아직 승인 전 안내)
|
||||
- src/App.tsx (RequireAuth / OnboardingGate / PendingApprovalGate /
|
||||
RootRedirect / 미정의 경로 → /)
|
||||
- src/lib/auth.tsx (userFromMe 필드, initialPathForUser, roleHomePath)
|
||||
|
||||
중복 회피: auth.spec.ts(실 API 도메인 정책·실 온보딩 완주),
|
||||
full-sweep-auth.spec.ts(아바타 업로드·문서 폴백·pending 새로고침 승인 경로),
|
||||
full-sweep-shell.spec.ts(로그아웃·미정의 경로의 role-aware 루트)와 겹치지
|
||||
않도록 이 파일은 입력 검증·미승인 유지 경로·역할별 루트·관리자 온보딩
|
||||
우회 분기 등 다른 각도만 다룬다.
|
||||
===================================================================== */
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
/** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
/** OAuth·dev 로그인 구성 fixture (Login.tsx가 GET /auth/config로 조회). */
|
||||
const READY_AUTH_CONFIG = {
|
||||
google_oauth_configured: true,
|
||||
saml_configured: false,
|
||||
providers: [
|
||||
{ provider: "google", configured: true, enabled: true, login_path: "/auth/login?provider=google" },
|
||||
{ provider: "saml", configured: false, enabled: false, login_path: "/auth/login?provider=saml" },
|
||||
],
|
||||
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||
dev_login_enabled: false,
|
||||
};
|
||||
|
||||
async function routeAuthConfig(page: Page, body: unknown = READY_AUTH_CONFIG) {
|
||||
await page.route("**/api/auth/config", (route) => route.fulfill(jsonRoute(body)));
|
||||
}
|
||||
|
||||
/** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 /auth/me 응답. */
|
||||
function makeMe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_id: "e2e-uc-auth-1",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /auth/me를 동적 본문으로 고정한다(나중 등록이 catch-all보다 우선). */
|
||||
async function routeMe(page: Page, body: () => Record<string, unknown> | null) {
|
||||
await page.route("**/api/auth/me", (route) => {
|
||||
const current = body();
|
||||
if (current === null) {
|
||||
return route.fulfill(jsonRoute({ detail: "not_authenticated" }, 401));
|
||||
}
|
||||
return route.fulfill(jsonRoute(current));
|
||||
});
|
||||
}
|
||||
|
||||
async function routeUnauthenticated(page: Page) {
|
||||
await routeMe(page, () => null);
|
||||
}
|
||||
|
||||
/** 온보딩 화면이 GET /users/me로 읽는 미완료 학습자 프로필 fixture. */
|
||||
const FRESH_PROFILE = {
|
||||
user_id: "e2e-uc-auth-1",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "",
|
||||
legal_name: "",
|
||||
affiliation: "",
|
||||
department: "",
|
||||
grade_level: "",
|
||||
phone: "",
|
||||
contact_address: "",
|
||||
nickname: "",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
cohort_ids: [],
|
||||
};
|
||||
|
||||
/** 온보딩 화면이 GET /users/legal-docs로 읽는 약관 fixture. */
|
||||
const LEGAL_DOCS = {
|
||||
terms: {
|
||||
kind: "terms",
|
||||
status: "draft",
|
||||
title: "서비스 이용약관",
|
||||
version: "v0.9-draft",
|
||||
body: "제1조(목적) 이 약관은 Vignette 교육용 시뮬레이션 서비스의 이용 조건을 규정합니다.",
|
||||
},
|
||||
privacy: {
|
||||
kind: "privacy",
|
||||
status: "draft",
|
||||
title: "개인정보 처리방침",
|
||||
version: "v0.9-draft",
|
||||
body: "제1조(수집 항목) 이름, 소속, 연락처를 가입 확인 목적으로 수집합니다.",
|
||||
},
|
||||
source_note: "운영 전 초안 문서입니다.",
|
||||
};
|
||||
|
||||
async function routeOnboardingData(page: Page) {
|
||||
await page.route("**/api/users/me", (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
return route.fulfill(jsonRoute(FRESH_PROFILE));
|
||||
});
|
||||
await page.route("**/api/users/legal-docs", (route) =>
|
||||
route.fulfill(jsonRoute(LEGAL_DOCS)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 온보딩 미완료 learner로 인증된 상태 + 온보딩 데이터 fixture. */
|
||||
async function setupFreshLearnerOnOnboarding(page: Page) {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
|
||||
await routeOnboardingData(page);
|
||||
}
|
||||
|
||||
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
|
||||
async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
|
||||
await page.getByLabel("닉네임").fill("비넷 유스케이스");
|
||||
await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 경청 반응을 연습합니다.");
|
||||
await page.getByLabel("이름").fill("유스케이스 학습자");
|
||||
await page.getByLabel("소속").fill("한신대학교");
|
||||
await page.getByLabel("학과/부서").fill("상담심리학과");
|
||||
await page.getByLabel("학년/직위").fill("2학년");
|
||||
await page.getByLabel("연락처").fill(phone);
|
||||
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
|
||||
}
|
||||
|
||||
test.describe("uc auth-onboarding", () => {
|
||||
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·허용 도메인·비치료 고지를 읽는다
|
||||
test("로그인 화면에서 서비스 성격과 허용 도메인, 비치료 고지를 확인한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeUnauthenticated(page);
|
||||
await routeAuthConfig(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
await expect(page.locator(".lg-lead")).toContainText(
|
||||
"학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.",
|
||||
);
|
||||
|
||||
// 허용 도메인 안내: 브랜드 패널 배지 + 버튼 서브라벨.
|
||||
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
|
||||
await expect(page.locator(".lg-policy b").first()).toHaveText("hs.ac.kr");
|
||||
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
|
||||
|
||||
// 비치료 고지: 교육용 도구임을 명시한다.
|
||||
await expect(page.locator(".lg-note")).toContainText(
|
||||
"교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 로그인하지 않은 사용자가 보호 경로 딥링크(북마크)로 들어가면 로그인 화면으로 보낸다
|
||||
test("비인증 사용자가 보호 경로 딥링크로 들어가면 /login으로 이동한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeUnauthenticated(page);
|
||||
await routeAuthConfig(page);
|
||||
|
||||
for (const deepLink of ["/learn/practice", "/teach/supervision", "/settings"]) {
|
||||
await page.goto(deepLink);
|
||||
await page.waitForURL(/\/login(?:$|[/?#])/);
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 이미 로그인한 학습자가 /login 북마크를 다시 열면 자신의 홈으로 되돌린다
|
||||
test("로그인된 학습자가 /login을 다시 열면 학습자 홈으로 튕긴다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe());
|
||||
|
||||
await page.goto("/login");
|
||||
await page.waitForURL(/\/learn(?:$|[/?#])/);
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 온보딩에서 아무것도 입력하지 않거나 닉네임만 넣고 제출하려 한다
|
||||
test("온보딩 필수 입력이 비어 있으면 제출 버튼이 비활성 상태로 막힌다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
||||
|
||||
const submit = page.getByRole("button", { name: /가입 설정 완료/ });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// 닉네임만 채워도 나머지 필수 입력이 비어 있으면 여전히 제출할 수 없다.
|
||||
await page.getByLabel("닉네임").fill("절반만 입력");
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
|
||||
test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
await fillOnboardingTextFields(page);
|
||||
|
||||
const submit = page.getByRole("button", { name: /가입 설정 완료/ });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// 약관 하나만 동의해도 아직 부족하다.
|
||||
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// 개인정보 동의까지 체크하면 비로소 제출할 수 있다.
|
||||
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 동의 전에 약관·개인정보 문서를 펼쳐 실제 내용을 읽어 본다
|
||||
test("온보딩 약관·개인정보 문서를 펼쳐 제목·버전·본문을 읽는다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
const details = page.locator(".ob-legal details");
|
||||
await expect(details).toHaveCount(2);
|
||||
|
||||
await expect(details.nth(0).locator("summary")).toContainText("서비스 이용약관");
|
||||
await expect(details.nth(0).locator("summary b")).toHaveText("v0.9-draft");
|
||||
await details.nth(0).locator("summary").click();
|
||||
await expect(details.nth(0).locator(".ob-doc-text")).toContainText("제1조(목적)");
|
||||
|
||||
await expect(details.nth(1).locator("summary")).toContainText("개인정보 처리방침");
|
||||
await details.nth(1).locator("summary").click();
|
||||
await expect(details.nth(1).locator(".ob-doc-text")).toContainText("제1조(수집 항목)");
|
||||
|
||||
// 문서 출처 주석(source_note)도 함께 표시된다.
|
||||
await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다.");
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다
|
||||
test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
let onboardingPosted = false;
|
||||
await page.route("**/api/users/me/onboarding", (route) => {
|
||||
onboardingPosted = true;
|
||||
return route.fulfill(jsonRoute(FRESH_PROFILE));
|
||||
});
|
||||
|
||||
await page.goto("/onboarding");
|
||||
await fillOnboardingTextFields(page, "12"); // 숫자 9자리 미만 → isValidPhone 실패
|
||||
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
|
||||
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
|
||||
|
||||
await page.getByRole("button", { name: /가입 설정 완료/ }).click();
|
||||
const alert = page.locator(".ob-form > .ob-error[role='alert']");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요");
|
||||
await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/);
|
||||
expect(onboardingPosted).toBe(false);
|
||||
});
|
||||
|
||||
// usecase: 승인 대기 중인 학습자가 새로고침 버튼을 눌러 보지만 아직 승인 전이다
|
||||
test("승인 대기 화면에서 새로고침해도 아직 승인 전이면 시각과 함께 안내한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe({ account_status: "pending", approval_required: true }));
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||
|
||||
await page.getByRole("button", { name: "승인 상태 새로고침" }).click();
|
||||
await expect(page.locator(".pa-status")).toContainText(
|
||||
"기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.",
|
||||
);
|
||||
await expect(page).toHaveURL(/\/pending(?:$|[/?#])/);
|
||||
});
|
||||
|
||||
// usecase: 승인 대기 계정이 온보딩·학습 딥링크를 직접 열어도 승인 대기 화면으로 강제 이동한다
|
||||
test("pending 계정은 온보딩·학습 딥링크에서도 /pending으로 강제 이동한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () =>
|
||||
makeMe({ account_status: "pending", approval_required: true, onboarding_completed_at: null }),
|
||||
);
|
||||
|
||||
for (const deepLink of ["/onboarding", "/learn", "/settings"]) {
|
||||
await page.goto(deepLink);
|
||||
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 승인·온보딩을 마친 학습자가 루트 주소로 들어와 자신의 홈으로 안내받는다
|
||||
test("학습자가 루트(/)로 들어오면 /learn으로 리디렉트된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe());
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/learn(?:$|[/?#])/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 루트 주소로 들어와 교수 콘솔로 안내받는다
|
||||
test("교수자가 루트(/)로 들어오면 /teach로 리디렉트된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () =>
|
||||
makeMe({
|
||||
user_id: "e2e-uc-auth-teacher",
|
||||
email: "teacher@hs.ac.kr",
|
||||
role: "teacher",
|
||||
display_name: "교수자",
|
||||
nickname: "교수자",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/teach(?:$|[/?#])/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "instructor");
|
||||
});
|
||||
|
||||
// usecase: 관리자가 루트 주소로 들어와 운영 콘솔로 안내받는다
|
||||
test("관리자가 루트(/)로 들어오면 /admin으로 리디렉트된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () =>
|
||||
makeMe({
|
||||
user_id: "e2e-uc-auth-admin",
|
||||
email: "admin@twentyoz.kr",
|
||||
role: "admin",
|
||||
display_name: "관리자",
|
||||
nickname: "관리자",
|
||||
admin_access: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/admin(?:$|[/?#])/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 브라우저를 새로고침해도 저장된 세션(auth/me)으로 로그인 상태가 복원된다
|
||||
test("새로고침 후에도 auth/me 기반으로 세션이 복원되어 /learn에 머문다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe());
|
||||
|
||||
await page.goto("/learn");
|
||||
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
// usecase: 사용자가 잘못된 주소를 입력하면 상태에 맞는 화면(/login 또는 /pending)으로 정리된다
|
||||
test("알 수 없는 경로는 비인증이면 /login, 승인 대기면 /pending으로 정리된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthConfig(page);
|
||||
|
||||
// 비인증 사용자의 오타 주소 → 루트 정리 → /login.
|
||||
let me: Record<string, unknown> | null = null;
|
||||
await routeMe(page, () => me);
|
||||
await page.goto("/no-such-route");
|
||||
await page.waitForURL(/\/login(?:$|[/?#])/);
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
|
||||
// 승인 대기 계정의 오타 주소 → /pending.
|
||||
me = makeMe({ account_status: "pending", approval_required: true });
|
||||
await page.goto("/typo/deeper/route");
|
||||
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||
});
|
||||
|
||||
// usecase: 온보딩을 아직 안 끝낸 관리자가 운영 콘솔 주소로 바로 들어간다(관리자 우회 분기)
|
||||
test("온보딩 미완료 관리자는 /admin 작업 공간에 온보딩 없이 바로 접근한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () =>
|
||||
makeMe({
|
||||
user_id: "e2e-uc-auth-admin-fresh",
|
||||
email: "admin@twentyoz.kr",
|
||||
role: "admin",
|
||||
display_name: "관리자",
|
||||
nickname: "관리자",
|
||||
admin_access: true,
|
||||
onboarding_completed_at: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/admin");
|
||||
// OnboardingGate의 isAdminWorkspacePath 분기: /onboarding으로 밀려나지 않는다.
|
||||
await expect(page).toHaveURL(/\/admin(?:$|[/?#])/);
|
||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
|
||||
});
|
||||
});
|
||||
799
apps/web/e2e/uc-error-resilience-shell.spec.ts
Normal file
799
apps/web/e2e/uc-error-resilience-shell.spec.ts
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
/* =====================================================================
|
||||
uc-error-resilience-shell.spec.ts — 유스케이스: 오류 복원력·셸·프라이버시 표면
|
||||
|
||||
목적: 서버가 느리거나(지연), 끊기거나(단절), 5xx/401을 돌려주거나,
|
||||
라우트 렌더 자체가 실패하는 상황에서 사용자가
|
||||
· 빈 화면 대신 중립 로딩/자연어 오류/복구 수단을 보고
|
||||
· 진행 중 입력·상태를 잃지 않고
|
||||
· 스택트레이스·raw JSON·raw UUID·[NAME] 마스킹 토큰 같은
|
||||
내부 표현을 화면에서 보지 않는지
|
||||
를 실제 사용 행동 단위로 검증한다.
|
||||
|
||||
근거(실제 소스 계약):
|
||||
- src/App.tsx: AuthRestoreGate(auth-restore-retrying/failed/retry testid),
|
||||
RouteErrorBoundary + RouteErrorFallback("화면을 표시하지 못했습니다",
|
||||
route-error-reload), BootScreen("불러오는 중…"), RequireAuth.
|
||||
- src/lib/auth.tsx: restoreCurrentUser 재시도(0/400/1000ms), AUTH_EXPIRED_EVENT
|
||||
수신 시 user를 비워 보호 라우트가 /login으로 회수.
|
||||
- src/lib/api.ts: ApiError 표준화, 401 시 notifyAuthExpired 이벤트.
|
||||
- src/lib/piiDisplay.ts: displayPiiSafeText — [NAME] 등 토큰의 조사 자연화.
|
||||
- src/pages/LearnerHome.tsx + learner-home/model.ts: 로딩 스켈레톤,
|
||||
"…불러오지 못했습니다" 자연어 오류, 빈 상태(role=status) 구분,
|
||||
toggleArchiveSession(archiveBusyId 이중 클릭 방지, 실패 notice),
|
||||
대시보드 리캡의 displayPiiSafeText 적용.
|
||||
- src/pages/Session.tsx: personaLoadState missing/error 구분 문구
|
||||
(sx-prestart__note), 회기 시작 게이트.
|
||||
- src/components/shell/Topbar.tsx: 오류 상태에서도 유지되는 공통 셸.
|
||||
|
||||
중복 회피:
|
||||
- full-sweep-shell.spec.ts: suspense fallback·stale chunk 1회 reload·
|
||||
unknown route·로그아웃 실패 시 /login 복귀·pending 가드 → 다루지 않음.
|
||||
- uc-session-conversation.spec.ts: 턴 전송 500 입력 보존·스트림 오류 재시도·
|
||||
세션 자막의 [NAME] → 다루지 않음(여기는 대시보드 리캡 표면).
|
||||
- uc-settings-consent.spec.ts: 설정 저장 실패·철회 실패 → 다루지 않음.
|
||||
- uc-session-review-core.spec.ts: 리뷰 화면 raw UUID·워크시트 저장 실패 →
|
||||
다루지 않음(여기는 학습자 홈 기록 표면).
|
||||
|
||||
전부 route fixture 기반(mocked-auth 패턴, catch-all 먼저)이며
|
||||
실제 AI 엔진 턴 생성은 일절 없다.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const NOW = Date.now();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
|
||||
|
||||
// raw UUID 비노출 검증용 — 화면 어디에도 그대로 보여선 안 되는 식별자.
|
||||
const ACTIVE_UUID = "9c8d7e6f-5a4b-4c3d-8e2f-1a0b9c8d7e6f";
|
||||
const ENDED_UUID = "3f2b8a9e-6c1d-4e5f-9a7b-2c8d4e6f1a3b";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
/** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
/** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 승인 완료 학습자. */
|
||||
const ME_LEARNER = {
|
||||
user_id: "e2e-uc-resilience-1",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
};
|
||||
|
||||
async function routeAuthedLearner(page: Page) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(jsonRoute(ME_LEARNER)),
|
||||
);
|
||||
}
|
||||
|
||||
const PERSONAS_BODY = [
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "성하늘",
|
||||
difficulty: "easy",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "20대" },
|
||||
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
{
|
||||
code: "P2",
|
||||
display_name: "김도윤",
|
||||
difficulty: "moderate",
|
||||
theory_target: ["cbt"],
|
||||
demographics: { age_band: "30대" },
|
||||
presenting_summary: "직장 내 대인관계 갈등을 호소",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
];
|
||||
|
||||
function activeSession(sessionId: string) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
session_no: 2,
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(1 * HOUR),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 6,
|
||||
learner_turn_count: 3,
|
||||
client_turn_count: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function endedSession(sessionId: string, archived = false) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
session_no: 1,
|
||||
persona_code: "P2",
|
||||
persona_name: "김도윤",
|
||||
status: "ended",
|
||||
stage: "라포",
|
||||
started_at: iso(5 * HOUR),
|
||||
ended_at: iso(4.5 * HOUR),
|
||||
review_ready: false,
|
||||
archived,
|
||||
archived_at: archived ? iso(0.1 * HOUR) : null,
|
||||
turn_count: 4,
|
||||
learner_turn_count: 2,
|
||||
client_turn_count: 2,
|
||||
};
|
||||
}
|
||||
|
||||
const DASHBOARD_BODY = {
|
||||
message: "학습 현황 요약",
|
||||
source: "runtime",
|
||||
overview: {
|
||||
total_sessions: 2,
|
||||
active_sessions: 1,
|
||||
review_ready_sessions: 0,
|
||||
completed_sessions: 1,
|
||||
archived_sessions: 0,
|
||||
learner_turns: 5,
|
||||
client_turns: 5,
|
||||
last_practiced_at: iso(1 * HOUR),
|
||||
},
|
||||
growth: {
|
||||
trend: "insufficient",
|
||||
avg_rapport: null,
|
||||
avg_score: null,
|
||||
evaluated_sessions: 0,
|
||||
latest_score: null,
|
||||
first_score: null,
|
||||
score_delta: null,
|
||||
top_techniques: [],
|
||||
points: [],
|
||||
},
|
||||
recent_feedback: [],
|
||||
persona_progress: [],
|
||||
achievements: [],
|
||||
};
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
...DASHBOARD_BODY,
|
||||
overview: {
|
||||
...DASHBOARD_BODY.overview,
|
||||
total_sessions: 0,
|
||||
active_sessions: 0,
|
||||
completed_sessions: 0,
|
||||
learner_turns: 0,
|
||||
client_turns: 0,
|
||||
last_practiced_at: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 학습자 홈 정상 데이터 fixture 일괄 등록 (catch-all·auth 포함). */
|
||||
async function installLearnerFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
sessions?: () => unknown[];
|
||||
dashboard?: unknown;
|
||||
} = {},
|
||||
) {
|
||||
const { sessions, dashboard = DASHBOARD_BODY } = options;
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
sessions: sessions
|
||||
? sessions()
|
||||
: [activeSession("ucres-act1"), endedSession("ucres-end1")],
|
||||
source: "runtime",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(dashboard)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Login.tsx가 GET /auth/config로 읽는 OAuth 준비 fixture. */
|
||||
const READY_AUTH_CONFIG = {
|
||||
google_oauth_configured: true,
|
||||
saml_configured: false,
|
||||
providers: [
|
||||
{
|
||||
provider: "google",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
login_path: "/auth/login?provider=google",
|
||||
},
|
||||
],
|
||||
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||
dev_login_enabled: false,
|
||||
};
|
||||
|
||||
test.describe("uc error-resilience-shell", () => {
|
||||
// usecase: 서버가 느릴 때 학습자는 빈 화면·오류 대신 중립 로딩 화면을 보다가, 응답이 오면 그대로 홈에 진입한다
|
||||
test("인증 부트스트랩이 지연되는 동안 중립 로딩을 유지하고 응답 후 정상 진입한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let releaseMe!: () => void;
|
||||
const meGate = new Promise<void>((resolve) => {
|
||||
releaseMe = resolve;
|
||||
});
|
||||
await routeUnmockedApi(page);
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await meGate;
|
||||
await route.fulfill(jsonRoute(ME_LEARNER));
|
||||
});
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
|
||||
source: "runtime",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
||||
);
|
||||
|
||||
await page.goto("/learn");
|
||||
|
||||
// 응답 전: 중립 BootScreen만 보이고 오류·로그인 회수가 없다.
|
||||
await expect(page.getByText("불러오는 중…")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0);
|
||||
|
||||
releaseMe();
|
||||
|
||||
// 응답 후: 같은 주소에서 학습자 홈이 그대로 열린다.
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
});
|
||||
|
||||
// usecase: 서버 재구동 직후처럼 연결이 끊겼을 때 학습자는 세션을 잃었다는 오해 대신 재연결 안내와 복구 실패 진단을 본다
|
||||
test("부트스트랩 연결 단절 시 재연결 안내를 거쳐 진단 화면과 재연결 버튼을 보여준다", async ({
|
||||
page,
|
||||
}) => {
|
||||
// 1·2번째 시도는 즉시 실패, 3번째 시도는 게이트로 붙잡아
|
||||
// '재연결 중' 화면을 결정론적으로 관찰한 뒤 실패를 확정한다.
|
||||
let attempts = 0;
|
||||
let releaseFinal!: () => void;
|
||||
const finalGate = new Promise<void>((resolve) => {
|
||||
releaseFinal = resolve;
|
||||
});
|
||||
await routeUnmockedApi(page);
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
attempts += 1;
|
||||
if (attempts >= 3) await finalGate;
|
||||
await route.abort().catch(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/learn");
|
||||
|
||||
// 재시도 동안: 세션을 버리지 않는다는 재연결 안내(role=status).
|
||||
const retrying = page.getByTestId("auth-restore-retrying");
|
||||
await expect(retrying).toBeVisible();
|
||||
await expect(retrying).toContainText("서버 재연결");
|
||||
await expect(retrying).toContainText("로그인 권한을 다시 확인하고 있습니다.");
|
||||
|
||||
releaseFinal();
|
||||
|
||||
// 재시도 소진 후: 권한 상실이 아님을 못박는 진단 화면 + 재연결 버튼.
|
||||
const failed = page.getByTestId("auth-restore-failed");
|
||||
await expect(failed).toBeVisible();
|
||||
await expect(failed).toContainText("서버 연결 진단");
|
||||
await expect(failed).toContainText("관리자 권한이 사라진 것이 아닙니다.");
|
||||
await expect(page.getByTestId("auth-restore-retry")).toBeVisible();
|
||||
// 이 화면은 로그인으로 튕기지 않고 같은 주소에서 복구를 기다린다.
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
// usecase: 서버가 정상화된 뒤 학습자가 '서버에 다시 연결'을 눌러 재로그인 없이 같은 세션으로 복귀한다
|
||||
test("복구 실패 화면에서 서버 정상화 후 재연결 버튼으로 같은 세션에 복귀한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let apiUp = false;
|
||||
await routeUnmockedApi(page);
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
if (!apiUp) {
|
||||
await route.abort().catch(() => {});
|
||||
return;
|
||||
}
|
||||
await route.fulfill(jsonRoute(ME_LEARNER));
|
||||
});
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
|
||||
source: "runtime",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
||||
);
|
||||
|
||||
await page.goto("/learn");
|
||||
await expect(page.getByTestId("auth-restore-failed")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
apiUp = true;
|
||||
await page.getByTestId("auth-restore-retry").click();
|
||||
|
||||
// 재로그인(/login) 없이 같은 주소에서 학습자 홈이 복원된다.
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
await expect(page.locator(".vg-topbar__uname")).toHaveText("학습자");
|
||||
});
|
||||
|
||||
// usecase: 데이터 API가 느릴 때 학습자는 연습 화면에서 스켈레톤·확인 문구를 보다가 응답이 오면 목록으로 전환된다
|
||||
test("연습 화면 데이터 지연 동안 스켈레톤을 보이다가 응답 후 목록으로 전환된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let releaseData!: () => void;
|
||||
const dataGate = new Promise<void>((resolve) => {
|
||||
releaseData = resolve;
|
||||
});
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, async (route) => {
|
||||
await dataGate;
|
||||
await route.fulfill(jsonRoute(PERSONAS_BODY));
|
||||
});
|
||||
await page.route(/\/api\/sessions$/, async (route) => {
|
||||
await dataGate;
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
|
||||
source: "runtime",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
||||
);
|
||||
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
// 지연 동안: 페르소나 스켈레톤 3개 + 기록 확인 문구, 오류 문구는 없다.
|
||||
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3);
|
||||
await expect(
|
||||
page.getByText("저장된 연습 기록을 확인하고 있습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("불러오지 못했습니다");
|
||||
|
||||
releaseData();
|
||||
|
||||
// 응답 후: 실제 페르소나 목록과 기존 회기 액션으로 전환된다.
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
await expect(listbox.getByRole("option", { name: /P1/ })).toBeVisible();
|
||||
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".lh-activity").getByRole("button", { name: "이어하기" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 네트워크가 끊겨도 학습자는 셸(톱바) 안에서 자연어 오류를 보고, 오류가 빈 기록 상태로 위장되지 않는다
|
||||
test("네트워크 단절 시 자연어 오류를 표시하고 셸을 유지하며 빈 상태와 구분한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) => route.abort());
|
||||
await page.route(/\/api\/sessions$/, (route) => route.abort());
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) => route.abort());
|
||||
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
// 페르소나 목록 실패: role=alert 자연어 안내.
|
||||
const personaError = page.locator(".lh-error");
|
||||
await expect(personaError).toBeVisible();
|
||||
await expect(personaError).toContainText("내담자 목록을 불러오지 못했습니다.");
|
||||
await expect(personaError).toContainText("잠시 뒤 다시 시도해 주세요.");
|
||||
// 기록 실패: 인라인 자연어 안내.
|
||||
await expect(
|
||||
page.getByText("연습 기록을 불러오지 못했습니다."),
|
||||
).toBeVisible();
|
||||
// 실패를 '기록 없음' 빈 상태로 위장하지 않는다.
|
||||
await expect(page.locator("body")).not.toContainText(
|
||||
"저장된 기존 회기가 없습니다.",
|
||||
);
|
||||
// 공통 셸은 살아 있어 로그아웃 등 탈출 경로가 유지된다.
|
||||
await expect(page.locator(".vg-topbar")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 서버가 스택트레이스가 담긴 5xx를 돌려줘도 학습자는 자연어 안내만 보고 내부 오류 본문은 보지 않는다
|
||||
test("5xx 응답의 스택트레이스·raw JSON이 화면에 노출되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const scaryBody = {
|
||||
detail:
|
||||
'Traceback (most recent call last):\n File "app/routes/sessions.py", line 42, in list_sessions\nKeyError: \'session_rows\'',
|
||||
};
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(scaryBody, 500)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(jsonRoute(scaryBody, 500)),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(scaryBody, 500)),
|
||||
);
|
||||
|
||||
await page.goto("/learn");
|
||||
|
||||
// 자연어 오류 표면만 보인다.
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "코칭을 만들 기록을 불러오지 못했습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("최근 평가 피드백을 불러오지 못했습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".lh-dashboard-feedback")).toContainText(
|
||||
"불러오기 실패",
|
||||
);
|
||||
// 내부 오류 본문·상태 표현은 어디에도 없다.
|
||||
const body = page.locator("body");
|
||||
await expect(body).not.toContainText("Traceback");
|
||||
await expect(body).not.toContainText("KeyError");
|
||||
await expect(body).not.toContainText('"detail"');
|
||||
await expect(body).not.toContainText("API 500");
|
||||
});
|
||||
|
||||
// usecase: 사용 중 세션이 만료(401)되면 진행하던 화면 대신 로그인 화면으로 안전하게 회수된다
|
||||
test("사용 중 401이 발생하면 로그인 화면으로 복귀한다", async ({ page }) => {
|
||||
await installLearnerFixtures(page);
|
||||
await page.route("**/api/auth/config", (route) =>
|
||||
route.fulfill(jsonRoute(READY_AUTH_CONFIG)),
|
||||
);
|
||||
await page.route("**/api/sessions/ucres-end1/archive", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "session expired" }, 401)),
|
||||
);
|
||||
|
||||
await page.goto("/learn/history");
|
||||
const cards = page.locator(".lh-session-list");
|
||||
await expect(cards).toBeVisible();
|
||||
|
||||
// 보관 액션이 401을 받으면 전역 인증 만료로 처리되어 /login으로 회수된다.
|
||||
await cards.getByRole("button", { name: "보관", exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 보관이 서버 오류로 실패해도 학습자의 검색어와 기록 상태는 사라지지 않고 실패 안내를 받는다
|
||||
test("보관 실패(500) 시 실패 안내가 뜨고 검색어·기록 상태가 보존된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLearnerFixtures(page);
|
||||
await page.route("**/api/sessions/ucres-end1/archive", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "internal error" }, 500)),
|
||||
);
|
||||
|
||||
await page.goto("/learn/history");
|
||||
const search = page.getByRole("textbox", { name: "기록 검색" });
|
||||
await search.fill("김도윤");
|
||||
const list = page.locator(".lh-session-list");
|
||||
await expect(list.locator(".lh-session-card")).toHaveCount(1);
|
||||
|
||||
const archiveButton = list.getByRole("button", { name: "보관", exact: true });
|
||||
await archiveButton.click();
|
||||
|
||||
// 실패 안내(role=status)와 함께 상태가 그대로 남는다.
|
||||
await expect(
|
||||
page.getByText("보관 상태를 바꾸지 못했습니다."),
|
||||
).toBeVisible();
|
||||
await expect(search).toHaveValue("김도윤");
|
||||
await expect(archiveButton).toBeVisible();
|
||||
await expect(archiveButton).toBeEnabled();
|
||||
await expect(list.locator(".lh-session-card")).toHaveCount(1);
|
||||
// 실패인데 성공 문구로 위장하지 않는다.
|
||||
await expect(page.locator("body")).not.toContainText("회기를 보관했습니다.");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 보관을 조급하게 연타해도 요청은 한 번만 나가고, 처리 중에는 버튼이 잠긴다
|
||||
test("보관 더블클릭 시 요청이 1회만 전송되고 처리 중 버튼이 잠긴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let archiveCalls = 0;
|
||||
let archivedNow = false;
|
||||
let releaseArchive!: () => void;
|
||||
const archiveGate = new Promise<void>((resolve) => {
|
||||
releaseArchive = resolve;
|
||||
});
|
||||
await installLearnerFixtures(page, {
|
||||
sessions: () => [
|
||||
activeSession("ucres-act1"),
|
||||
endedSession("ucres-end1", archivedNow),
|
||||
],
|
||||
});
|
||||
await page.route("**/api/sessions/ucres-end1/archive", async (route) => {
|
||||
archiveCalls += 1;
|
||||
await archiveGate;
|
||||
archivedNow = true;
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: "ucres-end1",
|
||||
archived: true,
|
||||
archived_at: iso(0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto("/learn/history");
|
||||
const list = page.locator(".lh-session-list");
|
||||
const archiveButton = list.getByRole("button", { name: "보관", exact: true });
|
||||
await expect(archiveButton).toBeVisible();
|
||||
|
||||
await archiveButton.dblclick();
|
||||
|
||||
// 처리 중: 같은 자리 버튼이 '처리 중'으로 잠긴다.
|
||||
const busyButton = list.getByRole("button", { name: "처리 중" });
|
||||
await expect(busyButton).toBeVisible();
|
||||
await expect(busyButton).toBeDisabled();
|
||||
|
||||
releaseArchive();
|
||||
|
||||
// 성공 안내 후 재조회된 목록에서 카드가 '복원' 액션으로 바뀐다.
|
||||
await expect(page.getByText("회기를 보관했습니다.")).toBeVisible();
|
||||
await expect(
|
||||
list.getByRole("button", { name: "복원", exact: true }),
|
||||
).toBeVisible();
|
||||
expect(archiveCalls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 저장 마스킹된 [NAME] 토큰이 대시보드 리캡에서 자연스러운 한국어 문장으로 보인다
|
||||
test("대시보드 리캡의 [NAME] 마스킹이 조사까지 자연어로 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLearnerFixtures(page);
|
||||
await page.route(`**/api/sessions/ucres-act1`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
case_id: "case-ucres",
|
||||
session_id: "ucres-act1",
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(1 * HOUR),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
theory_mode: "humanistic",
|
||||
goal_stages: ["라포", "탐색"],
|
||||
effective_openness: 0.4,
|
||||
duration_limit_seconds: 1200,
|
||||
warning_before_end_seconds: 600,
|
||||
progress: null,
|
||||
turns: [
|
||||
{
|
||||
turn_seq: 1,
|
||||
speaker: "learner",
|
||||
stage: "라포",
|
||||
text: "[NAME]은 지난주에 어떻게 지내셨어요?",
|
||||
created_at: iso(0.9 * HOUR),
|
||||
},
|
||||
{
|
||||
turn_seq: 2,
|
||||
speaker: "client",
|
||||
stage: "라포",
|
||||
text: "요즘 [NAME]과 이야기할 때마다 마음이 무거워요.",
|
||||
created_at: iso(0.8 * HOUR),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.goto("/learn");
|
||||
await expect(page.locator(".lh-recap")).toBeVisible();
|
||||
|
||||
// 내담자 인용: [NAME]과 → 익명 내담자와 (받침 없는 라벨의 조사 선택).
|
||||
await expect(page.locator(".lh-recap__quote p")).toContainText(
|
||||
"익명 내담자와 이야기할 때마다 마음이 무거워요.",
|
||||
);
|
||||
// 학습자 발화: [NAME]은 → 익명 내담자는.
|
||||
await expect(page.locator(".lh-recap__learner p")).toContainText(
|
||||
"익명 내담자는 지난주에 어떻게 지내셨어요?",
|
||||
);
|
||||
// 마스킹 토큰 원문은 화면 어디에도 남지 않는다.
|
||||
await expect(page.locator("body")).not.toContainText("[NAME]");
|
||||
});
|
||||
|
||||
// usecase: 기록 카드에는 내담자 이름·상태만 보이고 내부 세션 UUID는 노출되지 않는다
|
||||
test("기록 화면에 raw 세션 UUID가 노출되지 않는다", async ({ page }) => {
|
||||
await installLearnerFixtures(page, {
|
||||
sessions: () => [activeSession(ACTIVE_UUID), endedSession(ENDED_UUID)],
|
||||
});
|
||||
|
||||
await page.goto("/learn/history");
|
||||
const cards = page.locator(".lh-session-list .lh-session-card");
|
||||
await expect(cards).toHaveCount(2);
|
||||
await expect(cards.first()).toContainText("성하늘");
|
||||
await expect(cards.last()).toContainText("김도윤");
|
||||
|
||||
// 사람이 읽는 표면에 내부 UUID가 그대로 보이지 않는다.
|
||||
const body = page.locator("body");
|
||||
await expect(body).not.toContainText(ACTIVE_UUID);
|
||||
await expect(body).not.toContainText(ENDED_UUID);
|
||||
});
|
||||
|
||||
// usecase: 기록이 정말 0건일 때는 오류가 아니라 빈 상태 안내로 첫 회기를 이끈다
|
||||
test("기록 0건은 오류가 아닌 빈 상태(role=status)로 안내한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLearnerFixtures(page, {
|
||||
sessions: () => [],
|
||||
dashboard: EMPTY_DASHBOARD,
|
||||
});
|
||||
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const empty = page.locator(".lh-activity__empty");
|
||||
await expect(empty).toBeVisible();
|
||||
await expect(empty).toContainText("저장된 기존 회기가 없습니다.");
|
||||
await expect(empty).toContainText(
|
||||
"첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.",
|
||||
);
|
||||
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
|
||||
// 빈 상태를 오류 문구로 표현하지 않는다.
|
||||
await expect(page.locator("body")).not.toContainText("불러오지 못했습니다");
|
||||
});
|
||||
|
||||
// usecase: 존재하지 않는 내담자 딥링크로 들어온 학습자는 '목록에 없음' 안내를 받고 회기 시작이 잠긴다
|
||||
test("없는 페르소나 딥링크는 '목록에 없음' 안내와 함께 시작을 차단한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: null })),
|
||||
);
|
||||
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.goto("/learn/session/ZZ99");
|
||||
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||
|
||||
// 존재하지 않음(missing)은 서버 오류와 구분되는 전용 문구로 안내된다.
|
||||
await expect(page.locator(".sx-prestart__note")).toHaveText(
|
||||
"ZZ99 페르소나는 현재 연습 목록에 없습니다. 학습자 홈에서 사용 가능한 페르소나를 선택해 주세요.",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "회기 시작" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 연습 목록 조회가 실패하면 '없음'이 아니라 '불러오지 못함'으로 구분해 안내하고 시작을 잠근다
|
||||
test("페르소나 목록 로드 실패는 missing과 구분되는 오류 문구로 안내한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) => route.abort());
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: null })),
|
||||
);
|
||||
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.goto("/learn/session/P1");
|
||||
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||
|
||||
await expect(page.locator(".sx-prestart__note")).toHaveText(
|
||||
"연습 목록을 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요.",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "회기 시작" }),
|
||||
).toBeDisabled();
|
||||
// 로드 실패를 '목록에 없음'으로 단정하지 않는다.
|
||||
await expect(page.locator("body")).not.toContainText(
|
||||
"페르소나는 현재 연습 목록에 없습니다",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 라우트 렌더가 예외로 죽으면 빈 화면 대신 진단 화면을 보고, 복구 버튼으로 정상 화면에 돌아온다
|
||||
test("라우트 렌더 실패 시 오류 경계 진단 화면이 뜨고 다시 불러오기로 복구된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let chunkDown = true;
|
||||
await installLearnerFixtures(page);
|
||||
// dev 모듈 URL 차단으로 lazy 라우트 렌더 실패를 재현한다.
|
||||
await page.route("**/src/pages/LearnerHome.tsx*", async (route) => {
|
||||
if (chunkDown) {
|
||||
await route.abort().catch(() => {});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/learn");
|
||||
|
||||
// RouteErrorBoundary 진단 화면: 제목·경로·복구 액션.
|
||||
// (index.html 부트 워치독 패널도 role=alert를 잠깐 띄울 수 있어 문구로 한정)
|
||||
const fallback = page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "화면을 표시하지 못했습니다" });
|
||||
await expect(fallback).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
fallback.getByRole("heading", { name: "화면을 표시하지 못했습니다" }),
|
||||
).toBeVisible();
|
||||
await expect(fallback).toContainText("화면 진단");
|
||||
await expect(fallback).toContainText("/learn");
|
||||
await expect(
|
||||
fallback.getByRole("link", { name: "홈으로 이동" }),
|
||||
).toBeVisible();
|
||||
|
||||
// 모듈이 다시 제공되면 '최신 버전 다시 불러오기'로 정상 화면에 복귀한다.
|
||||
chunkDown = false;
|
||||
await page.getByTestId("route-error-reload").click();
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
});
|
||||
});
|
||||
655
apps/web/e2e/uc-learner-home-dashboard.spec.ts
Normal file
655
apps/web/e2e/uc-learner-home-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
// 2026-08-18 유스케이스 E2E — 학습자 홈 대시보드(learner-home-dashboard) 사용 여정.
|
||||
// 목적: 학습자가 대시보드에서 현황을 읽고(메트릭·라포 게이지), 추천/이어하기/새 회기
|
||||
// 시작으로 이동하고, 빈 대시보드·대량 기록·키보드 조작까지 실제 사용 행동 단위로 검증한다.
|
||||
// 근거: src/pages/LearnerHome.tsx, src/pages/learner-home/model.ts,
|
||||
// src/lib/personaViewModel.ts, src/lib/format.ts 의 실제 UI 계약.
|
||||
// 인증·데이터는 전부 route fixture(mocked-auth 패턴)로 고정하며,
|
||||
// 실제 AI 엔진 턴 생성은 일절 수행하지 않는다.
|
||||
// 중복 회피: full-sweep-learner-home.spec.ts(메트릭 4카드 populated 값·리뷰 우선 추천·
|
||||
// 스포트라이트 리캡·기록 필터/검색·보관·결정 D3)와 learner.spec.ts(실 API 레이아웃)는
|
||||
// 이미 커버 — 여기서는 평가 0회 메트릭, 리뷰 0건 추천 분기, 게이지 바 폭, 컴팩트 행
|
||||
// 이동, 새 회기 시작 경로, 50건 대량 렌더, 신규 빈 상태, 키보드 여정을 다룬다.
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const NOW = Date.now();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
|
||||
|
||||
const ACT_ID = "uclhact1";
|
||||
const END_ID = "uclhend1";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedLearner(page: Page) {
|
||||
await page.route("**/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "e2e-uc-learner-1",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// P1 기초(사용 가능) / P2 중간(사용 가능) / P3 고난도(degraded → 사용 불가)
|
||||
const PERSONAS_BODY = [
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "성하늘",
|
||||
difficulty: "easy",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "20대" },
|
||||
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
{
|
||||
code: "P2",
|
||||
display_name: "김도윤",
|
||||
difficulty: "moderate",
|
||||
theory_target: ["cbt"],
|
||||
demographics: { age_band: "30대" },
|
||||
presenting_summary: "직장 내 대인관계 갈등을 호소",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
{
|
||||
code: "P3",
|
||||
display_name: "이바다",
|
||||
difficulty: "hard",
|
||||
theory_target: ["psychodynamic"],
|
||||
demographics: { age_band: "40대" },
|
||||
presenting_summary: "만성적인 무기력을 호소",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 진행 중 1건(P1) + 종료(리뷰 없음) 1건(P2) — 리뷰 대기 0건 상태의 기본 세트.
|
||||
const S_ACT = {
|
||||
session_id: ACT_ID,
|
||||
session_no: 2,
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(1 * HOUR),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 6,
|
||||
learner_turn_count: 3,
|
||||
client_turn_count: 3,
|
||||
};
|
||||
|
||||
const S_END = {
|
||||
session_id: END_ID,
|
||||
session_no: 1,
|
||||
persona_code: "P2",
|
||||
persona_name: "김도윤",
|
||||
status: "ended",
|
||||
stage: "라포",
|
||||
started_at: iso(5 * HOUR),
|
||||
ended_at: iso(4.5 * HOUR),
|
||||
review_ready: false,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 4,
|
||||
learner_turn_count: 2,
|
||||
client_turn_count: 2,
|
||||
};
|
||||
|
||||
const BASE_SESSIONS = [S_ACT, S_END];
|
||||
|
||||
// 평가가 아직 없는 학습자 — 리뷰 0건, 평가 0회, 라포 평균 null.
|
||||
const DASH_NO_EVAL = {
|
||||
message: "학습 현황 요약",
|
||||
source: "runtime",
|
||||
overview: {
|
||||
total_sessions: 2,
|
||||
active_sessions: 1,
|
||||
review_ready_sessions: 0,
|
||||
completed_sessions: 1,
|
||||
archived_sessions: 0,
|
||||
learner_turns: 5,
|
||||
client_turns: 5,
|
||||
last_practiced_at: iso(1 * HOUR),
|
||||
},
|
||||
growth: {
|
||||
trend: "insufficient",
|
||||
avg_rapport: null,
|
||||
avg_score: null,
|
||||
evaluated_sessions: 0,
|
||||
latest_score: null,
|
||||
first_score: null,
|
||||
score_delta: null,
|
||||
top_techniques: [],
|
||||
points: [],
|
||||
},
|
||||
recent_feedback: [],
|
||||
persona_progress: [
|
||||
{
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
sessions: 1,
|
||||
active_sessions: 1,
|
||||
completed_sessions: 0,
|
||||
review_ready_sessions: 0,
|
||||
rapport_percent: 0,
|
||||
trend: "insufficient",
|
||||
latest_stage: "탐색",
|
||||
latest_score: null,
|
||||
latest_at: iso(1 * HOUR),
|
||||
},
|
||||
{
|
||||
persona_code: "P2",
|
||||
persona_name: "김도윤",
|
||||
sessions: 1,
|
||||
active_sessions: 0,
|
||||
completed_sessions: 1,
|
||||
review_ready_sessions: 0,
|
||||
rapport_percent: 0,
|
||||
trend: "insufficient",
|
||||
latest_stage: "라포",
|
||||
latest_score: null,
|
||||
latest_at: iso(4.5 * HOUR),
|
||||
},
|
||||
],
|
||||
achievements: [],
|
||||
};
|
||||
|
||||
// 라포 게이지 검증용 — 서로 다른 rapport_percent(81/47)를 가진 진행 기록.
|
||||
const DASH_RAPPORT = {
|
||||
...DASH_NO_EVAL,
|
||||
growth: {
|
||||
...DASH_NO_EVAL.growth,
|
||||
trend: "up",
|
||||
avg_rapport: 0.4,
|
||||
avg_score: 0.7,
|
||||
evaluated_sessions: 2,
|
||||
latest_score: 0.7,
|
||||
},
|
||||
persona_progress: [
|
||||
{
|
||||
...DASH_NO_EVAL.persona_progress[0],
|
||||
sessions: 3,
|
||||
completed_sessions: 2,
|
||||
rapport_percent: 81,
|
||||
trend: "up",
|
||||
},
|
||||
{
|
||||
...DASH_NO_EVAL.persona_progress[1],
|
||||
rapport_percent: 47,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
message: "학습 현황 요약",
|
||||
source: "runtime",
|
||||
overview: {
|
||||
total_sessions: 0,
|
||||
active_sessions: 0,
|
||||
review_ready_sessions: 0,
|
||||
completed_sessions: 0,
|
||||
archived_sessions: 0,
|
||||
learner_turns: 0,
|
||||
client_turns: 0,
|
||||
last_practiced_at: null,
|
||||
},
|
||||
growth: {
|
||||
trend: "insufficient",
|
||||
avg_rapport: null,
|
||||
avg_score: null,
|
||||
evaluated_sessions: 0,
|
||||
latest_score: null,
|
||||
first_score: null,
|
||||
score_delta: null,
|
||||
top_techniques: [],
|
||||
points: [],
|
||||
},
|
||||
recent_feedback: [],
|
||||
persona_progress: [],
|
||||
achievements: [],
|
||||
};
|
||||
|
||||
// 스포트라이트(진행 중 회기) 상세 — 리캡 fetch 오류 상태를 만들지 않기 위해 고정.
|
||||
const DETAIL_ACT = {
|
||||
case_id: "case-uclh",
|
||||
session_id: ACT_ID,
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(1 * HOUR),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
theory_mode: "humanistic",
|
||||
goal_stages: ["라포", "탐색"],
|
||||
effective_openness: 0.4,
|
||||
duration_limit_seconds: 1200,
|
||||
warning_before_end_seconds: 600,
|
||||
progress: null,
|
||||
turns: [
|
||||
{
|
||||
turn_seq: 1,
|
||||
speaker: "learner",
|
||||
stage: "라포",
|
||||
text: "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
|
||||
created_at: iso(0.9 * HOUR),
|
||||
},
|
||||
{
|
||||
turn_seq: 2,
|
||||
speaker: "client",
|
||||
stage: "라포",
|
||||
text: "요즘은 잠이 잘 안 와요.",
|
||||
created_at: iso(0.8 * HOUR),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 대량 렌더 검증용 — 종료 회기 50건.
|
||||
const BULK_SESSIONS = Array.from({ length: 50 }, (_, i) => {
|
||||
const persona = i % 2 === 0 ? { code: "P1", name: "성하늘" } : { code: "P2", name: "김도윤" };
|
||||
const stages = ["라포", "탐색", "개입", "정리"] as const;
|
||||
return {
|
||||
session_id: `uclhbulk${String(i + 1).padStart(2, "0")}`,
|
||||
session_no: 50 - i,
|
||||
persona_code: persona.code,
|
||||
persona_name: persona.name,
|
||||
status: "ended",
|
||||
stage: stages[i % 4],
|
||||
started_at: iso((i + 2) * HOUR),
|
||||
ended_at: iso((i + 1.5) * HOUR),
|
||||
review_ready: false,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 6,
|
||||
learner_turn_count: 3,
|
||||
client_turn_count: 3,
|
||||
};
|
||||
});
|
||||
|
||||
const DASH_BULK = {
|
||||
...DASH_NO_EVAL,
|
||||
overview: {
|
||||
...DASH_NO_EVAL.overview,
|
||||
total_sessions: 50,
|
||||
active_sessions: 0,
|
||||
completed_sessions: 50,
|
||||
learner_turns: 150,
|
||||
client_turns: 150,
|
||||
},
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
sessions?: unknown[];
|
||||
dashboard?: unknown;
|
||||
detail?: { session_id: string } | null;
|
||||
}
|
||||
|
||||
async function installFixtures(page: Page, options: FixtureOptions = {}) {
|
||||
const {
|
||||
sessions = BASE_SESSIONS,
|
||||
dashboard = DASH_NO_EVAL,
|
||||
detail = DETAIL_ACT,
|
||||
} = options;
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(jsonRoute({ sessions, source: "runtime" })),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(dashboard)),
|
||||
);
|
||||
if (detail) {
|
||||
await page.route(new RegExp(`/api/sessions/${detail.session_id}$`), (route) =>
|
||||
route.fulfill(jsonRoute(detail)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("uc learner home dashboard", () => {
|
||||
// usecase: 평가가 아직 없는 학습자가 메트릭 카드에서 현황 수치와 빈-평가 문구를 읽는다
|
||||
test("평가 전 학습자가 메트릭 카드에서 진행·리뷰·평가·라포 수치를 확인한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
|
||||
const cards = page.locator(".lh-dashboard-status");
|
||||
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||
const progressCard = cards.getByRole("button", { name: /진행 회기/ });
|
||||
await expect(progressCard).toContainText("2회");
|
||||
await expect(progressCard).toContainText("1회 종료 · 1회 진행");
|
||||
const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ });
|
||||
await expect(reviewCard).toContainText("0건");
|
||||
await expect(reviewCard).toContainText("대기 중인 리뷰 없음");
|
||||
const scoreCard = cards.getByRole("button", { name: /최근 평가/ });
|
||||
await expect(scoreCard).toContainText("평가 부족");
|
||||
await expect(scoreCard).toContainText("첫 회기 후 표시");
|
||||
const rapportCard = cards.getByRole("button", { name: /라포 흐름/ });
|
||||
await expect(rapportCard).toContainText("평가 부족");
|
||||
// topPersona — 회기 수 동률이면 최근 회기가 더 최신인 P1(성하늘)이 우선.
|
||||
await expect(rapportCard).toContainText("성하늘 · 1회");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 '리뷰 대기' 메트릭 카드를 눌러 기록 화면으로 이동한다
|
||||
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
|
||||
await page
|
||||
.locator(".lh-dashboard-status")
|
||||
.getByRole("button", { name: /리뷰 대기/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||
});
|
||||
|
||||
// usecase: 리뷰 대기가 없는 학습자가 추천 카드의 '연습 시작하기'로 연습 화면에 진입한다
|
||||
test("리뷰 0건이면 추천 카드가 개인 연습을 권하고 시작 CTA가 연습 화면으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
|
||||
const recommend = page.locator(".lh-dashboard-recommend");
|
||||
await expect(
|
||||
recommend.getByRole("heading", { name: "연습 목표를 좁힙니다." }),
|
||||
).toBeVisible();
|
||||
await expect(recommend).toContainText("개인 연습");
|
||||
// 최근 평균 학습자 턴 (3+2)/2 → 3턴(<4) → 탐색 질문 늘리기 추천.
|
||||
await expect(recommend).toContainText("탐색 질문 늘리기");
|
||||
await recommend.getByRole("button", { name: /연습 시작하기/ }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 성장 지표 탭에서 페르소나별 라포 게이지 %와 바 폭을 읽는다
|
||||
test("성장 지표 탭의 라포 게이지가 rapport_percent 값과 바 폭을 함께 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, { dashboard: DASH_RAPPORT });
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const rapport = page.getByLabel("페르소나별 라포 누적");
|
||||
const rows = rapport.locator(".lh-rapport-row");
|
||||
await expect(rows).toHaveCount(2);
|
||||
// 회기 수 내림차순 — P1(3회, 81%)이 먼저.
|
||||
await expect(rows.nth(0)).toContainText("성하늘");
|
||||
await expect(rows.nth(0)).toContainText("3회");
|
||||
await expect(rows.nth(0).locator("b")).toHaveText("81%");
|
||||
const firstBar = rows.nth(0).getByRole("progressbar");
|
||||
await expect(firstBar).toHaveAttribute("aria-valuenow", "81");
|
||||
await expect(firstBar.locator("i")).toHaveAttribute("style", /width:\s*81%/);
|
||||
await expect(rows.nth(1).locator("b")).toHaveText("47%");
|
||||
await expect(rows.nth(1).getByRole("progressbar")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"47",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 기록·리뷰 탭의 최근 기록에서 진행 중 회기를 눌러 이어한다
|
||||
test("기록·리뷰 탭 최근 기록의 '진행 중' 버튼으로 회기를 이어간다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
|
||||
|
||||
const recentPanel = page.locator("#lh-panel-records .lh-panel--records").first();
|
||||
await expect(
|
||||
recentPanel.getByRole("heading", { name: "방금 끝낸 흐름입니다." }),
|
||||
).toBeVisible();
|
||||
await recentPanel.getByRole("button", { name: "진행 중" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
||||
});
|
||||
|
||||
// usecase: 학습자가 기록·리뷰 탭의 최근 기록에서 종료 회기를 눌러 기록 리뷰로 이동한다
|
||||
test("기록·리뷰 탭 최근 기록의 '종료' 버튼이 해당 회기 리뷰 화면으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
|
||||
|
||||
const recentPanel = page.locator("#lh-panel-records .lh-panel--records").first();
|
||||
await recentPanel.getByRole("button", { name: "종료", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${END_ID}/review$`));
|
||||
});
|
||||
|
||||
// usecase: 학습자가 연습 화면의 기존 회기 목록에서 진행 중 회기를 이어간다
|
||||
test("연습 화면 기존 회기 목록의 '이어하기'가 진행 중 회기로 복귀한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
// 기본 선택 P1 — 기존 회기 목록에 진행 중(탐색 단계) 회기가 보인다.
|
||||
const activity = page.locator(".lh-activity");
|
||||
await expect(activity).toContainText("P1 · 1회");
|
||||
await activity.getByRole("button", { name: "이어하기" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
||||
});
|
||||
|
||||
// usecase: 학습자가 내담자를 바꿔 선택한 뒤 '새 회기 시작'으로 새 세션 경로에 진입한다
|
||||
test("다른 내담자를 선택하고 '새 회기 시작'을 누르면 해당 페르소나 세션 경로로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
await listbox.getByRole("option", { name: /P2/ }).click();
|
||||
await expect(listbox.getByRole("option", { name: /P2/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다
|
||||
test("페르소나 카드가 난이도 라벨과 주호소 설명을 표기한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const p1 = listbox.getByRole("option", { name: /P1/ });
|
||||
await expect(p1).toContainText("기초 · 인간중심");
|
||||
await expect(p1).toContainText("학업 스트레스와 수면 문제를 호소");
|
||||
const p2 = listbox.getByRole("option", { name: /P2/ });
|
||||
await expect(p2).toContainText("중간 · 인지행동");
|
||||
await expect(p2).toContainText("직장 내 대인관계 갈등을 호소");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 사용 불가 내담자를 구분하고 선택이 막힌 것을 확인한다
|
||||
test("degraded 페르소나 카드는 '사용 불가' 표시와 함께 선택이 비활성화된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const p3 = listbox.getByRole("option", { name: /P3/ });
|
||||
await expect(p3).toContainText("사용 불가");
|
||||
await expect(p3).toContainText(
|
||||
"카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.",
|
||||
);
|
||||
await expect(p3).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(p3).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 내담자 선택을 바꾸면 미리보기·기존 회기 카운트가 함께 갱신된다
|
||||
test("내담자 선택을 바꾸면 미리보기 정보와 기존 회기 카운트가 갱신된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
// 기본 선택 P1 미리보기 확인.
|
||||
await expect(page.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||
await page
|
||||
.getByRole("listbox", { name: "연습 페르소나" })
|
||||
.getByRole("option", { name: /P2/ })
|
||||
.click();
|
||||
await expect(page.getByRole("heading", { name: "김도윤" })).toBeVisible();
|
||||
const facts = page.locator(".lh-facts");
|
||||
await expect(facts).toContainText("중간");
|
||||
await expect(facts).toContainText("인지행동");
|
||||
await expect(page.locator(".lh-summary")).toContainText(
|
||||
"직장 내 대인관계 갈등을 호소",
|
||||
);
|
||||
await expect(page.locator(".lh-activity__head")).toContainText("P2 · 1회");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 종료 회기 50건이 쌓인 기록 화면을 끝까지 스크롤해 살펴본다
|
||||
test("세션 50건 대량 기록이 전부 렌더되고 끝까지 스크롤해 볼 수 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, { sessions: BULK_SESSIONS, dashboard: DASH_BULK, detail: null });
|
||||
await page.goto("/learn/history");
|
||||
|
||||
await expect(page.locator(".lh-history-main__head")).toContainText("50개");
|
||||
const cards = page.locator(".lh-session-list .lh-session-card");
|
||||
await expect(cards).toHaveCount(50);
|
||||
// 가장 오래된 회기까지 스크롤 — 마지막 카드가 실제로 보인다.
|
||||
await cards.last().scrollIntoViewIfNeeded();
|
||||
await expect(cards.last()).toBeVisible();
|
||||
await expect(cards.last()).toContainText("김도윤");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 첫 방문 학습자가 빈 대시보드에서 첫 회기 안내를 읽고 연습 화면으로 이동한다
|
||||
test("신규 학습자 빈 대시보드가 첫 회기 안내와 함께 학습 대상 선택으로 이끈다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, { sessions: [], dashboard: EMPTY_DASHBOARD, detail: null });
|
||||
await page.goto("/learn");
|
||||
|
||||
// 코치가 첫 회기 시작을 안내한다.
|
||||
const coachCard = page.getByLabel("AI 코치");
|
||||
await expect(
|
||||
coachCard.getByRole("heading", { name: "첫 회기를 시작할 차례입니다." }),
|
||||
).toBeVisible();
|
||||
// 스포트라이트는 '첫 회기 준비' + 기초 우선 페르소나(P1 성하늘, 결정 D3).
|
||||
const focus = page.locator(".lh-session-focus");
|
||||
await expect(focus).toContainText("첫 회기 준비");
|
||||
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||
await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기");
|
||||
// 진행 회기 메트릭도 0회.
|
||||
await expect(
|
||||
page.locator(".lh-dashboard-status").getByRole("button", { name: /진행 회기/ }),
|
||||
).toContainText("0회");
|
||||
// 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다.
|
||||
const primaryAction = page.locator("[data-learner-primary-action]");
|
||||
await expect(primaryAction).toHaveAccessibleName("학습 대상 선택");
|
||||
await primaryAction.click();
|
||||
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||
});
|
||||
|
||||
// usecase: 첫 방문 학습자가 기록·성장 탭에서 빈 상태 안내 문구를 확인한다
|
||||
test("신규 학습자의 기록·성장 탭이 빈 상태 안내 문구를 보여준다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, { sessions: [], dashboard: EMPTY_DASHBOARD, detail: null });
|
||||
await page.goto("/learn");
|
||||
|
||||
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
|
||||
const records = page.locator("#lh-panel-records");
|
||||
await expect(records).toContainText("최근 기록이 없습니다.");
|
||||
await expect(records).toContainText("대기 중인 리뷰가 없습니다.");
|
||||
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
const growthPanel = page.locator("#lh-panel-growth");
|
||||
await expect(growthPanel).toContainText(
|
||||
"첫 회기를 끝내면 라포 누적이 표시됩니다.",
|
||||
);
|
||||
await expect(growthPanel).toContainText("첫 회기 이후 표시");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 회기를 시작한다
|
||||
test("키보드 Tab 이동과 Enter만으로 새 회기를 시작할 수 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const p1 = listbox.getByRole("option", { name: /P1/ });
|
||||
await expect(p1).toHaveAttribute("aria-selected", "true");
|
||||
// 선택된 페르소나 카드에서 시작해 Tab으로 '새 회기 시작' 버튼까지 이동한다.
|
||||
await p1.focus();
|
||||
const startButton = page.getByRole("button", { name: "새 회기 시작" });
|
||||
let reached = false;
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
if (await startButton.evaluate((el) => el === document.activeElement)) {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(reached, "Tab 이동으로 '새 회기 시작' 버튼에 도달해야 한다").toBe(true);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P1$/);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다
|
||||
test("대시보드 영역 탭이 화살표·End 키 이동을 지원한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
|
||||
const todayTab = page.getByRole("tab", { name: "오늘의 회기" });
|
||||
await todayTab.click();
|
||||
await expect(todayTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const recordsTab = page.getByRole("tab", { name: "기록 · 리뷰" });
|
||||
await expect(recordsTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(recordsTab).toBeFocused();
|
||||
await expect(page.locator("#lh-panel-records")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("End");
|
||||
const growthTab = page.getByRole("tab", { name: "성장 지표" });
|
||||
await expect(growthTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator("#lh-panel-growth")).toBeVisible();
|
||||
|
||||
// 왼쪽 끝에서 ArrowLeft는 마지막 탭으로 순환한다.
|
||||
await page.keyboard.press("Home");
|
||||
await expect(todayTab).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(growthTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
610
apps/web/e2e/uc-learner-practice-history.spec.ts
Normal file
610
apps/web/e2e/uc-learner-practice-history.spec.ts
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
// uc-learner-practice-history — 학습자 연습(practice)·기록(history) 뷰 유스케이스 스펙.
|
||||
//
|
||||
// 목적: 학습자가 연습 대상을 고르고 새 회기를 시작하는 여정과, 회기 기록을
|
||||
// 검색·필터·정렬 규칙에 따라 찾아 리뷰/재연습으로 진입하는 여정을 검증한다.
|
||||
// 근거: apps/web/src/pages/LearnerHome.tsx(practice/history 뷰),
|
||||
// apps/web/src/pages/learner-home/model.ts(정렬·라벨·빈 문구·날짜 표기 규칙).
|
||||
// full-sweep-learner-home.spec.ts가 이미 다루는 필터 카드·칩 단독 전환,
|
||||
// 아카이브 성공/실패 알림, 결정 D3 기본 케이스는 중복 커버하지 않고
|
||||
// (검색+필터 조합, degraded 스킵, 난도 동률 code 순 등) 다른 각도로 비튼다.
|
||||
// 모든 응답은 route fixture로 고정하며 실제 AI 엔진 턴 생성은 일절 없다.
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const NOW = Date.now();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedLearner(page: Page) {
|
||||
await page.route("**/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "e2e-learner-1",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: "2026-08-01T00:00:00+00:00",
|
||||
onboarding_completed_at: "2026-08-01T00:00:00+00:00",
|
||||
nickname: "학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 페르소나 fixture ─────────────────────────────────────────────────────────
|
||||
function makePersona(
|
||||
code: string,
|
||||
displayName: string,
|
||||
difficulty: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
display_name: displayName,
|
||||
difficulty,
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "20대" },
|
||||
presenting_summary: `${displayName}의 주호소 요약`,
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PERSONAS_BODY = [
|
||||
makePersona("P1", "성하늘", "easy", {
|
||||
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||
}),
|
||||
makePersona("P2", "김도윤", "moderate", {
|
||||
theory_target: ["cbt"],
|
||||
demographics: { age_band: "30대" },
|
||||
presenting_summary: "직장 내 대인관계 갈등을 호소",
|
||||
}),
|
||||
];
|
||||
|
||||
// ── 회기 fixture ────────────────────────────────────────────────────────────
|
||||
function makeSession(
|
||||
sessionId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
session_no: 1,
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "ended",
|
||||
stage: "라포",
|
||||
started_at: iso(5 * HOUR),
|
||||
ended_at: iso(4.5 * HOUR),
|
||||
review_ready: false,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 4,
|
||||
learner_turn_count: 2,
|
||||
client_turn_count: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ACT_ID = "ucact001";
|
||||
const REV_ID = "ucrev001";
|
||||
const END_ID = "ucend001";
|
||||
const ARC_ID = "ucarc001";
|
||||
|
||||
const S_ACT = makeSession(ACT_ID, {
|
||||
session_no: 4,
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(1 * HOUR),
|
||||
ended_at: null,
|
||||
turn_count: 6,
|
||||
learner_turn_count: 3,
|
||||
client_turn_count: 3,
|
||||
});
|
||||
const S_REV = makeSession(REV_ID, {
|
||||
session_no: 3,
|
||||
stage: "정리",
|
||||
started_at: iso(3 * HOUR),
|
||||
ended_at: iso(2.5 * HOUR),
|
||||
review_ready: true,
|
||||
turn_count: 10,
|
||||
learner_turn_count: 5,
|
||||
client_turn_count: 5,
|
||||
});
|
||||
const S_END = makeSession(END_ID, {
|
||||
session_no: 2,
|
||||
persona_code: "P2",
|
||||
persona_name: "김도윤",
|
||||
stage: "라포",
|
||||
});
|
||||
const S_ARC = makeSession(ARC_ID, {
|
||||
session_no: 1,
|
||||
stage: "개입",
|
||||
started_at: iso(8 * HOUR),
|
||||
ended_at: iso(7 * HOUR),
|
||||
archived: true,
|
||||
archived_at: iso(6 * HOUR),
|
||||
turn_count: 8,
|
||||
learner_turn_count: 4,
|
||||
client_turn_count: 4,
|
||||
});
|
||||
|
||||
const BASE_SESSIONS = [S_ACT, S_REV, S_END, S_ARC];
|
||||
|
||||
// 대시보드는 practice 통계(진행/리뷰/누적)와 history 카운트의 우선 소스라
|
||||
// 회기 fixture에서 결정론으로 파생해 응답 모양을 실제 타입과 맞춘다.
|
||||
function makeDashboard(sessions: Array<Record<string, unknown>>) {
|
||||
const active = sessions.filter((s) => s.status === "active").length;
|
||||
const ended = sessions.filter((s) => s.status === "ended").length;
|
||||
const review = sessions.filter(
|
||||
(s) => s.status === "ended" && s.review_ready && !s.archived,
|
||||
).length;
|
||||
const archived = sessions.filter((s) => s.archived).length;
|
||||
return {
|
||||
message: "학습 현황 요약",
|
||||
source: "runtime",
|
||||
overview: {
|
||||
total_sessions: sessions.length,
|
||||
active_sessions: active,
|
||||
review_ready_sessions: review,
|
||||
completed_sessions: ended,
|
||||
archived_sessions: archived,
|
||||
learner_turns: 0,
|
||||
client_turns: 0,
|
||||
last_practiced_at: sessions.length ? iso(1 * HOUR) : null,
|
||||
},
|
||||
growth: {
|
||||
trend: "insufficient",
|
||||
avg_rapport: null,
|
||||
avg_score: null,
|
||||
evaluated_sessions: 0,
|
||||
latest_score: null,
|
||||
first_score: null,
|
||||
score_delta: null,
|
||||
top_techniques: [],
|
||||
points: [],
|
||||
},
|
||||
recent_feedback: [],
|
||||
persona_progress: [],
|
||||
achievements: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function installFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
personas?: Array<Record<string, unknown>>;
|
||||
sessions?: Array<Record<string, unknown>>;
|
||||
} = {},
|
||||
) {
|
||||
const personas = options.personas ?? PERSONAS_BODY;
|
||||
const sessions = options.sessions ?? BASE_SESSIONS;
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(personas)),
|
||||
);
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(jsonRoute({ sessions, source: "runtime" })),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(makeDashboard(sessions))),
|
||||
);
|
||||
}
|
||||
|
||||
const personaListbox = (page: Page) =>
|
||||
page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
|
||||
test.describe("uc learner practice/history", () => {
|
||||
// usecase: 학습자가 연습 화면에서 다른 내담자를 고른 뒤 새 회기를 시작한다.
|
||||
test("페르소나를 선택하고 새 회기를 시작하면 해당 내담자 세션으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = personaListbox(page);
|
||||
// 기록 보유 학습자 → 기본 선택은 API 순서 첫 사용 가능 페르소나(P1).
|
||||
await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
await listbox.getByRole("option", { name: /P2/ }).click();
|
||||
await expect(listbox.getByRole("option", { name: /P2/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
// 미리보기 제목·주호소가 선택 페르소나로 갱신된다.
|
||||
await expect(page.getByRole("heading", { name: "김도윤" })).toBeVisible();
|
||||
await expect(page.locator(".lh-summary p")).toHaveText(
|
||||
"직장 내 대인관계 갈등을 호소",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||
});
|
||||
|
||||
// usecase: 기록이 없는 신규 학습자에게 기초 난도부터 추천하되, 사용 불가
|
||||
// (degraded) 페르소나는 건너뛰고 다음 사용 가능한 기초 페르소나를 고른다.
|
||||
test("신규 학습자 기본 추천은 사용 불가 기초 페르소나를 건너뛴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, {
|
||||
personas: [
|
||||
makePersona("P9", "한겨울", "hard"),
|
||||
makePersona("P2", "김도윤", "easy", { degraded: true }),
|
||||
makePersona("P5", "박새봄", "easy"),
|
||||
],
|
||||
sessions: [],
|
||||
});
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = personaListbox(page);
|
||||
await expect(listbox.getByRole("option", { name: /P5/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(listbox.getByRole("option", { name: /P9/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "박새봄" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 신규 학습자에게 같은 난도가 여러 명이면 code 오름차순 첫 번째를 추천한다.
|
||||
test("신규 학습자 기본 추천은 난도 동률이면 코드 오름차순으로 고른다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, {
|
||||
// API 응답 순서는 PB가 먼저 — D3가 없으면 PB가 기본 선택된다.
|
||||
personas: [
|
||||
makePersona("PB", "이가을", "easy"),
|
||||
makePersona("PA", "정봄이", "easy"),
|
||||
],
|
||||
sessions: [],
|
||||
});
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = personaListbox(page);
|
||||
await expect(listbox.getByRole("option", { name: /PA/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(listbox.getByRole("option", { name: /PB/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 사용 불가 페르소나는 목록에서 구분 표기되고 선택할 수 없다.
|
||||
test("사용 불가 페르소나는 비활성 표기되고 클릭해도 선택되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, {
|
||||
personas: [
|
||||
makePersona("P1", "성하늘", "easy"),
|
||||
makePersona("P3", "최다온", "moderate", { degraded: true }),
|
||||
],
|
||||
sessions: [],
|
||||
});
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const listbox = personaListbox(page);
|
||||
const degradedOption = listbox.getByRole("option", { name: /P3/ });
|
||||
await expect(degradedOption).toBeDisabled();
|
||||
await expect(degradedOption).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(degradedOption.locator(".lh-persona__flag")).toHaveText(
|
||||
"사용 불가",
|
||||
);
|
||||
await expect(degradedOption).toContainText(
|
||||
"카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.",
|
||||
);
|
||||
|
||||
// 비활성 버튼 클릭 시도 후에도 선택은 P1을 유지한다.
|
||||
await degradedOption.click({ force: true });
|
||||
await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(degradedOption).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
// usecase: 연습 화면에서 페르소나를 바꾸면 '기존 회기' 목록이 그 내담자
|
||||
// 기록으로 갱신되어 이어가기/리뷰 판단을 돕는다.
|
||||
test("페르소나 선택에 따라 기존 회기 목록이 해당 내담자 기록으로 바뀐다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const recent = page.locator(".lh-activity__recent");
|
||||
// 기본 선택 P1 — P1 회기 3건(진행·리뷰·보관).
|
||||
// head는 Kicker("기존 회기")와 카운트 span 두 개라 마지막 span을 읽는다.
|
||||
await expect(recent.locator(".lh-activity__head > span").last()).toHaveText(
|
||||
"P1 · 3회",
|
||||
);
|
||||
await expect(recent.locator(".lh-session-card")).toHaveCount(3);
|
||||
await expect(recent).toContainText("성하늘");
|
||||
await expect(recent).not.toContainText("김도윤");
|
||||
|
||||
await personaListbox(page).getByRole("option", { name: /P2/ }).click();
|
||||
await expect(recent.locator(".lh-activity__head > span").last()).toHaveText(
|
||||
"P2 · 1회",
|
||||
);
|
||||
await expect(recent.locator(".lh-session-card")).toHaveCount(1);
|
||||
await expect(recent).toContainText("김도윤");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 연습 화면 상단 통계로 진행/리뷰/누적 현황을 한눈에 본다.
|
||||
test("연습 화면 통계가 진행 중·리뷰 가능·누적 회기 수를 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/practice");
|
||||
|
||||
const stats = page.locator(".lh-activity__stats > span");
|
||||
await expect(stats).toHaveCount(3);
|
||||
await expect(stats.nth(0)).toContainText("진행 중");
|
||||
await expect(stats.nth(0).locator("b")).toHaveText("1");
|
||||
await expect(stats.nth(1)).toContainText("리뷰 가능");
|
||||
await expect(stats.nth(1).locator("b")).toHaveText("1");
|
||||
await expect(stats.nth(2)).toContainText("누적 회기");
|
||||
await expect(stats.nth(2).locator("b")).toHaveText("4");
|
||||
});
|
||||
|
||||
// usecase: 기록 화면에서 각 회기 카드의 상태(진행 중/리뷰 가능/종료/보관됨)와
|
||||
// 다음 행동 버튼(이어하기/리뷰/기록)을 구분해 읽는다.
|
||||
test("기록 카드가 상태별 라벨과 행동 버튼을 구분해 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const cards = page.locator(".lh-session-list .lh-session-card");
|
||||
await expect(cards).toHaveCount(4);
|
||||
|
||||
// 정렬(ended_at ?? started_at 내림차순): 진행중 → 리뷰 → 종료 → 보관.
|
||||
const expectations = [
|
||||
{ status: "진행 중", bucket: "이어갈 회기", action: "이어하기" },
|
||||
{ status: "리뷰 가능", bucket: "AI 피드백 대기", action: "리뷰" },
|
||||
{ status: "종료", bucket: "종료 기록", action: "기록" },
|
||||
{ status: "보관됨", bucket: "보관 처리됨", action: "기록" },
|
||||
];
|
||||
for (const [index, item] of expectations.entries()) {
|
||||
const card = cards.nth(index);
|
||||
await expect(card.locator(".lh-session-card__status b")).toHaveText(
|
||||
item.status,
|
||||
);
|
||||
await expect(card.locator(".lh-session-card__status small")).toHaveText(
|
||||
item.bucket,
|
||||
);
|
||||
await expect(
|
||||
card.getByRole("button", { name: item.action, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
// 진행 중 카드에는 보관 버튼이 없고, 종료 카드에는 있다.
|
||||
await expect(
|
||||
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
cards.nth(3).getByRole("button", { name: "복원", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 리뷰 준비된 기록 카드에서 '리뷰'를 눌러 회기 리뷰로 진입한다.
|
||||
test("기록 카드의 리뷰 버튼이 해당 회기 리뷰 화면으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const revCard = page.locator(".lh-session-card", {
|
||||
hasText: "AI 피드백 대기",
|
||||
});
|
||||
await expect(revCard).toHaveCount(1);
|
||||
await revCard.getByRole("button", { name: "리뷰", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
||||
});
|
||||
|
||||
// usecase: 종료된 기록 카드에서 '다시 연습'을 눌러 같은 내담자로 새 회기를 연다.
|
||||
test("기록 카드의 다시 연습 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
||||
await expect(endCard).toHaveCount(1);
|
||||
await endCard
|
||||
.getByRole("button", { name: "다시 연습", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||
});
|
||||
|
||||
// usecase: 상태 필터 칩과 검색어를 함께 걸어 조건을 좁히고, 조건이 어긋나면
|
||||
// 빈 결과 문구를 확인한 뒤 필터를 되돌려 결과를 다시 찾는다.
|
||||
test("검색어와 상태 필터 칩을 조합해 기록을 좁힌다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const head = page.locator(".lh-history-main__head");
|
||||
const chips = page.locator(".lh-history-filter");
|
||||
const search = page.getByLabel("기록 검색");
|
||||
|
||||
// 진행 중 필터 + 종료 회기에만 있는 검색어 → 교집합 0건.
|
||||
await chips.getByRole("button", { name: "진행 중" }).click();
|
||||
await search.fill("김도윤");
|
||||
await expect(head).toContainText("0개");
|
||||
await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible();
|
||||
|
||||
// 같은 검색어로 필터만 전체로 되돌리면 1건이 다시 보인다.
|
||||
await chips.getByRole("button", { name: "전체" }).click();
|
||||
await expect(head).toContainText("1개");
|
||||
await expect(
|
||||
page.locator(".lh-session-list .lh-session-card"),
|
||||
).toContainText("김도윤");
|
||||
|
||||
// 검색은 상태 라벨("보관됨")로도 매칭된다.
|
||||
await search.fill("보관");
|
||||
await expect(head).toContainText("1개");
|
||||
await expect(
|
||||
page
|
||||
.locator(".lh-session-list .lh-session-card")
|
||||
.locator(".lh-session-card__status b"),
|
||||
).toHaveText("보관됨");
|
||||
});
|
||||
|
||||
// usecase: 기록 목록의 기본 정렬은 종료 시각 우선 최신순이라, 먼저 시작했어도
|
||||
// 나중에 끝난 회기가 목록 맨 위로 온다.
|
||||
test("기록 목록은 종료 시각(없으면 시작 시각) 기준 최신순으로 정렬된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, {
|
||||
sessions: [
|
||||
// 시작은 가장 이르지만 종료가 가장 최신 → 1위여야 한다.
|
||||
makeSession("ucsortA1", {
|
||||
persona_code: "PA",
|
||||
persona_name: "정렬첫째",
|
||||
started_at: iso(10 * HOUR),
|
||||
ended_at: iso(1 * HOUR),
|
||||
}),
|
||||
// 진행 중(ended_at 없음) → started_at(2시간 전) 기준 2위.
|
||||
makeSession("ucsortB1", {
|
||||
persona_code: "PB",
|
||||
persona_name: "정렬둘째",
|
||||
status: "active",
|
||||
stage: "탐색",
|
||||
started_at: iso(2 * HOUR),
|
||||
ended_at: null,
|
||||
}),
|
||||
// 종료 3시간 전 → 3위.
|
||||
makeSession("ucsortC1", {
|
||||
persona_code: "PC",
|
||||
persona_name: "정렬셋째",
|
||||
started_at: iso(4 * HOUR),
|
||||
ended_at: iso(3 * HOUR),
|
||||
}),
|
||||
],
|
||||
});
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const cards = page.locator(".lh-session-list .lh-session-card");
|
||||
await expect(cards).toHaveCount(3);
|
||||
await expect(cards.nth(0)).toContainText("정렬첫째");
|
||||
await expect(cards.nth(1)).toContainText("정렬둘째");
|
||||
await expect(cards.nth(2)).toContainText("정렬셋째");
|
||||
});
|
||||
|
||||
// usecase: 리뷰할 회기가 없는 상태에서 '리뷰 필요' 필터를 열면 검색어 없는
|
||||
// 필터 전용 빈 문구를 본다.
|
||||
test("리뷰 필요 필터에 해당 기록이 없으면 전용 빈 문구를 보여준다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, { sessions: [S_ACT, S_END] });
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const overview = page.locator(".lh-history-overview");
|
||||
await expect(overview.getByRole("button", { name: /리뷰 필요/ })).toContainText(
|
||||
"0",
|
||||
);
|
||||
await page
|
||||
.locator(".lh-history-filter")
|
||||
.getByRole("button", { name: "리뷰 필요" })
|
||||
.click();
|
||||
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
|
||||
await expect(
|
||||
page.getByText("리뷰가 필요한 회기가 없습니다."),
|
||||
).toBeVisible();
|
||||
|
||||
// 검색어가 있으면 문구가 검색 전용 문구로 바뀐다.
|
||||
await page.getByLabel("기록 검색").fill("성하늘");
|
||||
await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 오래 연습한 학습자가 100개 기록을 끝까지 스크롤해 살펴본다.
|
||||
test("100개 기록도 전부 렌더링되고 끝까지 스크롤해도 가로 넘침이 없다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bulk = Array.from({ length: 100 }, (_, i) =>
|
||||
makeSession(`ucbulk${String(i).padStart(3, "0")}`, {
|
||||
session_no: 100 - i,
|
||||
persona_code: i % 2 === 0 ? "P1" : "P2",
|
||||
persona_name: i % 2 === 0 ? "성하늘" : "김도윤",
|
||||
stage: (["라포", "탐색", "개입", "정리"] as const)[i % 4],
|
||||
started_at: iso((i + 2) * HOUR),
|
||||
ended_at: iso((i + 2) * HOUR - 30 * 60 * 1000),
|
||||
}),
|
||||
);
|
||||
await installFixtures(page, { sessions: bulk });
|
||||
await page.goto("/learn/history");
|
||||
|
||||
await expect(page.locator(".lh-history-main__head")).toContainText("100개");
|
||||
const cards = page.locator(".lh-session-list .lh-session-card");
|
||||
await expect(cards).toHaveCount(100);
|
||||
|
||||
await cards.nth(99).scrollIntoViewIfNeeded();
|
||||
await expect(cards.nth(99)).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 기록 카드에서 회기 시작 시각을 "N월 N일 오전/오후 HH:MM" 형식으로
|
||||
// 읽고, 손상된 날짜 값은 깨진 문구 없이 조용히 비워진다.
|
||||
test("기록 카드가 날짜·시간을 한국어 형식으로 표기하고 잘못된 날짜는 비운다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page, {
|
||||
sessions: [
|
||||
makeSession("ucdate001", {
|
||||
persona_name: "날짜확인",
|
||||
started_at: iso(2 * HOUR),
|
||||
ended_at: iso(1.5 * HOUR),
|
||||
}),
|
||||
makeSession("ucdate002", {
|
||||
persona_name: "손상날짜",
|
||||
started_at: "not-a-date",
|
||||
ended_at: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await page.goto("/learn/history");
|
||||
|
||||
const dated = page.locator(".lh-session-card", { hasText: "날짜확인" });
|
||||
const dateSpan = dated.locator(".lh-session-card__body > p > span").nth(1);
|
||||
// sessionDateLabel: Intl ko-KR { month:short, day:numeric, hour/minute 2-digit }.
|
||||
await expect(dateSpan).toHaveText(
|
||||
/^\d{1,2}월 \d{1,2}일 (오전|오후) \d{2}:\d{2}$/,
|
||||
);
|
||||
// 단계·턴 표기와 날짜가 한 줄에 함께 놓인다.
|
||||
await expect(
|
||||
dated.locator(".lh-session-card__body > p > span").nth(0),
|
||||
).toHaveText("라포 단계 · 4턴");
|
||||
|
||||
const broken = page.locator(".lh-session-card", { hasText: "손상날짜" });
|
||||
await expect(
|
||||
broken.locator(".lh-session-card__body > p > span").nth(1),
|
||||
).toHaveText("");
|
||||
await expect(broken).not.toContainText("Invalid");
|
||||
});
|
||||
});
|
||||
970
apps/web/e2e/uc-practice-g4-g5.spec.ts
Normal file
970
apps/web/e2e/uc-practice-g4-g5.spec.ts
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
/**
|
||||
* uc-practice-g4-g5.spec.ts — G4 처방·재연습 / G5 자기평가 잠금·전이 유스케이스 스펙.
|
||||
*
|
||||
* 목적: 회기 리뷰의 숙의 연습 카드(G4)와 캘리브레이션·전이 카드(G5)에서
|
||||
* 실제 학습자가 할 법한 행동(처방 CTA 이동, 재연습 근거 반영 전/후 비교,
|
||||
* 멱등 재확인, mastered 비임상 고지, 예측 잠금 순서, 전이 intent 쿼리 보존,
|
||||
* 홈 재연습 인계 패널)을 검증한다.
|
||||
*
|
||||
* 근거: apps/web/src/pages/session-review/DeliberatePracticeCard.tsx,
|
||||
* CalibrationTransferCard.tsx, src/lib/practiceLaunchIntent.ts,
|
||||
* src/pages/SessionReview.tsx(sr-practice-return), src/pages/LearnerHome.tsx
|
||||
* (lh-practice-launch-intent)의 실제 UI 계약만 어설션한다.
|
||||
*
|
||||
* 모든 응답은 route fixture다. 실제 AI 엔진 턴 생성 없음, 실 API 호출 없음.
|
||||
* 중복 회피: deliberate-practice.spec.ts(우선 처방 href 파싱·키보드 진입),
|
||||
* calibration-transfer.spec.ts(revise→lock→reveal 전체 루프, 0→1 기록),
|
||||
* self-directed-learning-loop.spec.ts(홈→회기→리뷰→재연습 전체 여정)와
|
||||
* 겹치지 않도록 각 테스트는 다른 각도(개별 CTA 클릭 이동, 1→1 멱등 표시,
|
||||
* mastered 게이트, 잠금 후 폼 소멸, 재실행 버튼 쿼리 보존)를 다룬다.
|
||||
*/
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import type {
|
||||
DeliberatePracticeReadModel,
|
||||
PracticeEpisodeItem,
|
||||
PracticePrescriptionItem,
|
||||
} from "../src/pages/session-review/deliberatePracticeApi";
|
||||
import type {
|
||||
ActualTransferExecutionResponse,
|
||||
CalibrationTransferReadModelResponse,
|
||||
} from "../src/pages/session-review/calibrationTransferApi";
|
||||
import {
|
||||
filledReviewResponse,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
type PracticeMode = PracticePrescriptionItem["activity_mode"];
|
||||
|
||||
const LEARNER_ID = "61000000-0000-4000-8000-000000000001";
|
||||
const SRC_SESSION_ID = "61000000-0000-4000-8000-0000000000a1";
|
||||
const RETRY_SESSION_ID = "61000000-0000-4000-8000-0000000000a2";
|
||||
const TURN_UUIDS = [
|
||||
"62000000-0000-4000-8000-000000000001",
|
||||
"62000000-0000-4000-8000-000000000002",
|
||||
"62000000-0000-4000-8000-000000000003",
|
||||
"62000000-0000-4000-8000-000000000004",
|
||||
"62000000-0000-4000-8000-000000000005",
|
||||
"62000000-0000-4000-8000-000000000006",
|
||||
];
|
||||
const G4_MODES: PracticeMode[] = [
|
||||
"replay",
|
||||
"branch",
|
||||
"constrained_response",
|
||||
"voice_retry",
|
||||
"difficulty_ladder",
|
||||
];
|
||||
const G4_PRESCRIPTION_ID = (mode: PracticeMode) => `uc-g4-${mode}`;
|
||||
const G4_CRITERION_ID = "criterion.reflect-and-check";
|
||||
|
||||
const G5_HISTORY_ID = "63000000-0000-4000-8000-000000000210";
|
||||
const G5_REVISION_ID = "63000000-0000-4000-8000-000000000211";
|
||||
const G5_PRESCRIPTION_ID = "63000000-0000-4000-8000-000000000413";
|
||||
const G5_SUITE_ID = "63000000-0000-4000-8000-000000000510";
|
||||
const G5_TRIAL_ID = "63000000-0000-4000-8000-000000000515";
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G4 read model fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function g4Activity(mode: PracticeMode, index: number) {
|
||||
const common = {
|
||||
scenario_variant_id: `uc-variant-${mode}`,
|
||||
scenario_novelty: "familiar" as const,
|
||||
difficulty_level: index,
|
||||
};
|
||||
if (mode === "replay") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.replay.launch" as const,
|
||||
pause_at_evidence_ref: TURN_UUIDS[1],
|
||||
};
|
||||
}
|
||||
if (mode === "branch") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.branch.launch" as const,
|
||||
branch_options: ["감정 확인", "의미 확인"],
|
||||
client_responses_hidden: true as const,
|
||||
};
|
||||
}
|
||||
if (mode === "constrained_response") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.constrained-response.launch" as const,
|
||||
required_moves: ["정서 반영", "이해 확인"],
|
||||
max_words: 24,
|
||||
};
|
||||
}
|
||||
if (mode === "voice_retry") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.voice-retry.launch" as const,
|
||||
acoustic_focus: ["쉼", "말 속도"],
|
||||
max_seconds: 20,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.difficulty-ladder.launch" as const,
|
||||
steps: [
|
||||
{
|
||||
level: 1,
|
||||
scenario_variant_id: "uc-ladder-familiar",
|
||||
scenario_novelty: "familiar" as const,
|
||||
variation: "내담자가 짧게 답하는 장면",
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
scenario_variant_id: "uc-ladder-unseen",
|
||||
scenario_novelty: "unseen_transfer" as const,
|
||||
variation: "내담자가 개입 의도를 되묻는 장면",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function g4Prescription(
|
||||
mode: PracticeMode,
|
||||
index: number,
|
||||
): PracticePrescriptionItem {
|
||||
const prescriptionId = G4_PRESCRIPTION_ID(mode);
|
||||
const competencyId = `competency.uc_${mode}`;
|
||||
const observable = `${mode} 장면에서 감정을 반영한 뒤 이해가 맞는지 확인한다.`;
|
||||
return {
|
||||
prescription_record_id: `64000000-0000-4000-8000-00000000000${index}`,
|
||||
prescription_key: prescriptionId,
|
||||
session_id: SRC_SESSION_ID,
|
||||
competency_id: competencyId,
|
||||
criterion_id: G4_CRITERION_ID,
|
||||
observable_behavior: observable,
|
||||
activity_mode: mode,
|
||||
scenario_variant_id: `uc-variant-${mode}`,
|
||||
scenario_novelty: "familiar",
|
||||
difficulty_level: index,
|
||||
prescription_payload: {
|
||||
schema_version: "vignette.practice-prescription.v1",
|
||||
event_name: "practice.prescribed",
|
||||
prescription_id: prescriptionId,
|
||||
coaching_card_id: `uc-g4-card-${mode}`,
|
||||
scene_id: `uc-scene-${mode}`,
|
||||
competency_id: competencyId,
|
||||
criterion_id: G4_CRITERION_ID,
|
||||
observable_behavior: observable,
|
||||
activity: g4Activity(mode, index),
|
||||
can_launch: true,
|
||||
evidence_refs: [
|
||||
{
|
||||
ref_id: TURN_UUIDS[1],
|
||||
scene_id: "uc-review-scene",
|
||||
turn_index: 2,
|
||||
actor: "learner",
|
||||
kind: "learner_behavior",
|
||||
},
|
||||
{
|
||||
ref_id: TURN_UUIDS[2],
|
||||
scene_id: "uc-review-scene",
|
||||
turn_index: 3,
|
||||
actor: "client",
|
||||
kind: "client_response",
|
||||
},
|
||||
],
|
||||
source_refs: ["synthetic:uc-g4-e2e:v1"],
|
||||
uncertainty: 0.3,
|
||||
counterevidence: [],
|
||||
},
|
||||
coach_claim: "같은 행동이 다른 장면에서도 유지되는지 확인합니다.",
|
||||
card_key: `uc-g4-card-${mode}`,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
source_refs: ["synthetic:uc-g4-e2e:v1"],
|
||||
uncertainty: 0.3,
|
||||
counterevidence: [],
|
||||
created_at: "2026-08-10T10:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function g4Episode(
|
||||
sessionId: string,
|
||||
progress: PracticeEpisodeItem["progress"],
|
||||
): PracticeEpisodeItem {
|
||||
return {
|
||||
episode_submission_id: "65000000-0000-4000-8000-000000000001",
|
||||
episode_key: `uc-episode-${progress}`,
|
||||
session_id: sessionId,
|
||||
progress,
|
||||
mastery_allowed: progress === "mastered",
|
||||
mastery_blockers:
|
||||
progress === "mastered" ? [] : ["unseen_transfer_not_verified"],
|
||||
uncertainty: 0.25,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
counterevidence: [],
|
||||
assessment_payload: {
|
||||
prescription_id: G4_PRESCRIPTION_ID("replay"),
|
||||
competency_id: "competency.uc_replay",
|
||||
} as unknown as PracticeEpisodeItem["assessment_payload"],
|
||||
created_at: "2026-08-10T10:05:00Z",
|
||||
attempts: [
|
||||
{
|
||||
attempt_record_id: "66000000-0000-4000-8000-000000000001",
|
||||
attempt_key: "uc-attempt-1",
|
||||
episode_submission_id: "65000000-0000-4000-8000-000000000001",
|
||||
sequence_no: 1,
|
||||
scenario_variant_id: "uc-variant-replay",
|
||||
scenario_novelty: "familiar",
|
||||
difficulty_level: 1,
|
||||
criterion_status: "observed",
|
||||
client_response: "engaged",
|
||||
outcome: "passed",
|
||||
utterance_template_id: "utterance-sha256:uc-fixture",
|
||||
learner_claimed_success: false,
|
||||
uncertainty: 0.25,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
counterevidence: [],
|
||||
attempt_payload: {},
|
||||
created_at: "2026-08-10T10:05:00Z",
|
||||
corrections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function g4ReadModel(options?: {
|
||||
episodes?: PracticeEpisodeItem[];
|
||||
familiarDemonstrations?: number;
|
||||
}): DeliberatePracticeReadModel {
|
||||
const familiar = options?.familiarDemonstrations ?? 0;
|
||||
return {
|
||||
learner_id: LEARNER_ID,
|
||||
clinical_claim_allowed: false,
|
||||
prescriptions: G4_MODES.map((mode, index) =>
|
||||
g4Prescription(mode, index + 1),
|
||||
),
|
||||
episodes: options?.episodes ?? [],
|
||||
competency_graph: {
|
||||
schema_version: "vignette.competency-graph.v1",
|
||||
definitions: G4_MODES.map((mode) => ({
|
||||
competency_id: `competency.uc_${mode}`,
|
||||
label_ko: mode === "replay" ? "공감적 반영 후 확인" : `${mode} 역량`,
|
||||
description: "하나의 행동을 장면에서 유지하는 연습 역량입니다.",
|
||||
prerequisite_ids: [],
|
||||
})),
|
||||
states: G4_MODES.map((mode, index) => ({
|
||||
competency_id: `competency.uc_${mode}`,
|
||||
band: mode === "replay" ? "fragile" : "developing",
|
||||
forgetting_risk: mode === "replay" ? 0.8 : 0.4 - index * 0.03,
|
||||
uncertainty: 0.3,
|
||||
attempt_count: mode === "replay" ? familiar : 0,
|
||||
familiar_demonstrations: mode === "replay" ? familiar : 0,
|
||||
unseen_transfer_demonstrations: 0,
|
||||
highest_familiar_difficulty: mode === "replay" ? 1 : 0,
|
||||
evidence_refs: [],
|
||||
counterevidence: [],
|
||||
})),
|
||||
},
|
||||
snapshot_id: "67000000-0000-4000-8000-000000000001",
|
||||
snapshot_no: 1,
|
||||
next_practice: {
|
||||
schema_version: "vignette.curriculum-decision.v1",
|
||||
selected_prescription_id: G4_PRESCRIPTION_ID("replay"),
|
||||
competency_id: "competency.uc_replay",
|
||||
competency_band: "fragile",
|
||||
forgetting_risk: 0.8,
|
||||
mode: "replay",
|
||||
selection_basis: [
|
||||
"weakest_available_band:fragile",
|
||||
"scenario_novelty:familiar",
|
||||
],
|
||||
deferred_prescription_ids: G4_MODES.slice(1).map(G4_PRESCRIPTION_ID),
|
||||
blocked_prescription_reasons: [],
|
||||
},
|
||||
decision_id: "68000000-0000-4000-8000-000000000001",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G5 read model fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function g5ReadModel(options?: {
|
||||
locked?: boolean;
|
||||
withSuite?: boolean;
|
||||
}): CalibrationTransferReadModelResponse {
|
||||
const model: CalibrationTransferReadModelResponse = {
|
||||
learner_id: LEARNER_ID,
|
||||
requested_view: "learner",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [
|
||||
{
|
||||
history_id: G5_HISTORY_ID,
|
||||
session_id: SRC_SESSION_ID,
|
||||
competency_id: "competency.empathic_reflection",
|
||||
practice_block_id: "uc-g5-block-001",
|
||||
scenario_variant_id: "uc-school-refusal",
|
||||
phrase_family_id: "uc-reflection-a",
|
||||
created_at: "2026-08-10T01:00:00Z",
|
||||
revisions: [
|
||||
{
|
||||
prediction_revision_id: G5_REVISION_ID,
|
||||
submission_id: "63000000-0000-4000-8000-000000000212",
|
||||
history_id: G5_HISTORY_ID,
|
||||
revision_no: 1,
|
||||
supersedes_prediction_revision_id: null,
|
||||
predicted_success_probability: 0.6,
|
||||
confidence: 0.55,
|
||||
recorded_sequence: 1,
|
||||
revision_reason: "반영 뒤 내담자 반응이 조금 열렸기 때문",
|
||||
source_kind: "learner_reported",
|
||||
perspective: "learner_self_report",
|
||||
instrument_id: "vignette.calibration-self-prediction",
|
||||
instrument_version: "1.0.0",
|
||||
evidence_turn_ids: [],
|
||||
created_at: "2026-08-10T01:00:00Z",
|
||||
},
|
||||
],
|
||||
lock: options?.locked
|
||||
? {
|
||||
lock_id: "63000000-0000-4000-8000-000000000310",
|
||||
submission_id: "63000000-0000-4000-8000-000000000311",
|
||||
history_id: G5_HISTORY_ID,
|
||||
prediction_revision_id: G5_REVISION_ID,
|
||||
locked_sequence: 2,
|
||||
created_at: "2026-08-10T01:02:00Z",
|
||||
}
|
||||
: null,
|
||||
external_observation: null,
|
||||
},
|
||||
],
|
||||
calibration_assessments: [],
|
||||
transfer_suites: options?.withSuite
|
||||
? [
|
||||
{
|
||||
transfer_suite_record_id: G5_SUITE_ID,
|
||||
submission_id: "63000000-0000-4000-8000-000000000511",
|
||||
suite_key: "uc-g5-suite-001",
|
||||
session_id: SRC_SESSION_ID,
|
||||
training_phrase_family_ids: ["uc-memorized-a"],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000512",
|
||||
instrument_id: "vignette.unseen-transfer",
|
||||
instrument_version: "1.0.0",
|
||||
data_classification: "synthetic_educational",
|
||||
clinical_claim_allowed: false,
|
||||
created_at: "2026-08-10T01:05:00Z",
|
||||
trials: [
|
||||
{
|
||||
transfer_trial_record_id: G5_TRIAL_ID,
|
||||
transfer_suite_record_id: G5_SUITE_ID,
|
||||
trial_key: "uc-g5-transfer-001",
|
||||
competency_id: "competency.empathic_reflection",
|
||||
scenario_variant_id: "family-conflict-confrontational",
|
||||
scenario_novelty: "unseen_transfer",
|
||||
context_variant: "family-conflict",
|
||||
relationship_style: "confrontational",
|
||||
difficulty_level: 5,
|
||||
expression_variant: "direct-anger",
|
||||
synthetic_subgroup: "synthetic-family-b",
|
||||
scenario_family_id: "family-conflict",
|
||||
phrase_family_id: "uc-novel-b",
|
||||
status: "failed",
|
||||
uncertainty: 0.22,
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000516"],
|
||||
counterevidence: ["client_rejected_reflection"],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000512",
|
||||
instrument_id: "vignette.unseen-transfer",
|
||||
instrument_version: "1.0.0",
|
||||
created_at: "2026-08-10T01:06:00Z",
|
||||
},
|
||||
],
|
||||
assessments: [],
|
||||
drift_reports: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
teacher_reviews: [],
|
||||
actual_executions: [],
|
||||
actual_transfer_assessments: [],
|
||||
};
|
||||
return model;
|
||||
}
|
||||
|
||||
function g5EmptyReadModel(): CalibrationTransferReadModelResponse {
|
||||
return {
|
||||
learner_id: LEARNER_ID,
|
||||
requested_view: "learner",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [],
|
||||
calibration_assessments: [],
|
||||
transfer_suites: [],
|
||||
teacher_reviews: [],
|
||||
actual_executions: [],
|
||||
actual_transfer_assessments: [],
|
||||
};
|
||||
}
|
||||
|
||||
function actualTransferResponse(
|
||||
idempotentReplay: boolean,
|
||||
): ActualTransferExecutionResponse {
|
||||
return {
|
||||
execution: {
|
||||
execution_event_id: "63000000-0000-4000-8000-000000000611",
|
||||
original_transfer_trial_record_id: G5_TRIAL_ID,
|
||||
practice_session_id: RETRY_SESSION_ID,
|
||||
competency_id: "competency.empathic_reflection",
|
||||
scenario_variant_id: "family-conflict-confrontational",
|
||||
scenario_novelty: "unseen_transfer",
|
||||
variation: {
|
||||
context_variant: "family-conflict",
|
||||
relationship_style: "confrontational",
|
||||
difficulty_level: 5,
|
||||
expression_variant: "direct-anger",
|
||||
synthetic_subgroup: "synthetic-family-b",
|
||||
scenario_family_id: "family-conflict",
|
||||
phrase_family_id: "uc-novel-c",
|
||||
},
|
||||
training_phrase_collision: false,
|
||||
status: "passed",
|
||||
uncertainty: 0.18,
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000612"],
|
||||
normalized_evaluator_labels: {
|
||||
technique_codes: ["reflection.feeling"],
|
||||
client_state_codes: ["engaged"],
|
||||
appropriateness: ["pos"],
|
||||
intent_deviation_dimensions: [],
|
||||
evaluator_error_count: 0,
|
||||
},
|
||||
counterevidence: [],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000613",
|
||||
created_at: "2026-08-10T02:00:00Z",
|
||||
},
|
||||
assessment: {
|
||||
evidence_source: "actual_practice_execution",
|
||||
competency_id: "competency.empathic_reflection",
|
||||
execution_count: 1,
|
||||
independent_execution_count: 1,
|
||||
observed_execution_count: 1,
|
||||
success_rate: 1,
|
||||
success_interval: { method: "wilson_95", lower: 0.21, upper: 1 },
|
||||
coverage: {
|
||||
contexts: 1,
|
||||
relationship_styles: 1,
|
||||
difficulty_levels: 1,
|
||||
expression_variants: 1,
|
||||
scenario_families: 1,
|
||||
phrase_families: 1,
|
||||
},
|
||||
phrase_family_collision_count: 0,
|
||||
eligible: false,
|
||||
actual_transfer_status: "insufficient_evidence",
|
||||
blockers: ["actual_observed_executions_below_minimum:1/6"],
|
||||
source_execution_event_ids: ["63000000-0000-4000-8000-000000000611"],
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000612"],
|
||||
},
|
||||
idempotent_replay: idempotentReplay,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routing helpers (mocked-auth 패턴: catch-all 먼저, 개별 fixture 나중)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function routeBase(page: Page, reviewSessionId: string) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
fulfillJson(route, { detail: "not part of this focused fixture" }, 404),
|
||||
);
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
fulfillJson(route, {
|
||||
user_id: LEARNER_ID,
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "UC 학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["uc-e2e"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "UC 학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
);
|
||||
const review = filledReviewResponse(reviewSessionId);
|
||||
review.turns = review.turns.map((turn, index) => ({
|
||||
...turn,
|
||||
turn_id: TURN_UUIDS[index],
|
||||
}));
|
||||
await page.route(`**/api/sessions/${reviewSessionId}/review`, (route) =>
|
||||
fulfillJson(route, review),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${reviewSessionId}/alliance-pulses`,
|
||||
(route) => fulfillJson(route, { items: [] }),
|
||||
);
|
||||
for (const suffix of ["outcome-trajectory", "ruptures"]) {
|
||||
await page.route(
|
||||
`**/api/sessions/${reviewSessionId}/${suffix}`,
|
||||
(route) => fulfillJson(route, { detail: "not found" }, 404),
|
||||
);
|
||||
}
|
||||
await routePrepostMeasures(page);
|
||||
}
|
||||
|
||||
async function routeG4(
|
||||
page: Page,
|
||||
model: () => DeliberatePracticeReadModel,
|
||||
) {
|
||||
await page.route("**/api/practice/learners/me", (route) =>
|
||||
fulfillJson(route, model()),
|
||||
);
|
||||
await page.route("**/api/calibration/learners/me", (route) =>
|
||||
fulfillJson(route, g5EmptyReadModel()),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeG5(
|
||||
page: Page,
|
||||
model: () => CalibrationTransferReadModelResponse,
|
||||
) {
|
||||
await page.route("**/api/calibration/learners/me", (route) =>
|
||||
fulfillJson(route, model()),
|
||||
);
|
||||
}
|
||||
|
||||
async function openFeedback(page: Page, path: string) {
|
||||
await page.goto(path);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
}
|
||||
|
||||
function deliberateSearch(): string {
|
||||
return new URLSearchParams({
|
||||
launch: "deliberate",
|
||||
prescription: G4_PRESCRIPTION_ID("replay"),
|
||||
source_session: SRC_SESSION_ID,
|
||||
criterion: G4_CRITERION_ID,
|
||||
novelty: "familiar",
|
||||
mode: "replay",
|
||||
}).toString();
|
||||
}
|
||||
|
||||
function transferSearch(): string {
|
||||
return new URLSearchParams({
|
||||
launch: "transfer",
|
||||
prescription: G5_PRESCRIPTION_ID,
|
||||
suite: G5_SUITE_ID,
|
||||
trial: G5_TRIAL_ID,
|
||||
source_session: SRC_SESSION_ID,
|
||||
criterion: "competency.empathic_reflection",
|
||||
novelty: "unseen_transfer",
|
||||
mode: "counterevidence_forecast",
|
||||
}).toString();
|
||||
}
|
||||
|
||||
async function clickPracticeCta(
|
||||
page: Page,
|
||||
mode: PracticeMode,
|
||||
): Promise<void> {
|
||||
await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`);
|
||||
const card = page.locator(".dp-card");
|
||||
await expect(card).toBeVisible();
|
||||
if (mode === "replay") {
|
||||
await card.getByRole("link", { name: "이 처방으로 연습 시작" }).click();
|
||||
return;
|
||||
}
|
||||
const queueLabel: Record<string, string> = {
|
||||
branch: "반응 분기 연습 열기",
|
||||
constrained_response: "제약 응답 연습 열기",
|
||||
voice_retry: "음성 재시도 연습 열기",
|
||||
difficulty_ladder: "난도 단계 연습 열기",
|
||||
};
|
||||
await card.getByText("뒤에 대기 중인 연습 4개").click();
|
||||
await card.getByRole("link", { name: queueLabel[mode] }).click();
|
||||
}
|
||||
|
||||
const MODE_HEADING: Record<PracticeMode, string> = {
|
||||
replay: "장면 다시 보기 처방을 이어받았습니다.",
|
||||
branch: "다른 반응 분기 처방을 이어받았습니다.",
|
||||
constrained_response: "제약 응답 처방을 이어받았습니다.",
|
||||
voice_retry: "음성 재시도 처방을 이어받았습니다.",
|
||||
difficulty_ladder: "난도 사다리 처방을 이어받았습니다.",
|
||||
};
|
||||
|
||||
async function expectLandedOnPractice(page: Page, mode: PracticeMode) {
|
||||
await expect(page).toHaveURL(/\/learn\/practice\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get("launch")).toBe("deliberate");
|
||||
expect(url.searchParams.get("prescription")).toBe(G4_PRESCRIPTION_ID(mode));
|
||||
expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID);
|
||||
expect(url.searchParams.get("criterion")).toBe(G4_CRITERION_ID);
|
||||
expect(url.searchParams.get("novelty")).toBe("familiar");
|
||||
expect(url.searchParams.get("mode")).toBe(mode);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: MODE_HEADING[mode] }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("UC G4·G5 — 처방 재연습과 자기평가 잠금·전이 여정", () => {
|
||||
// usecase: 학습자가 우선 처방(되감기) CTA를 눌러 처방 조건을 유지한 채 연습 화면으로 이동한다
|
||||
test("되감기 처방 CTA를 누르면 처방·기준·장면 조건이 보존된 연습 화면으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel());
|
||||
await clickPracticeCta(page, "replay");
|
||||
await expectLandedOnPractice(page, "replay");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대기 큐에서 반응 분기 처방을 골라 연습을 시작한다
|
||||
test("대기 큐의 반응 분기 CTA는 branch 모드 쿼리로 연습 화면을 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel());
|
||||
await clickPracticeCta(page, "branch");
|
||||
await expectLandedOnPractice(page, "branch");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대기 큐에서 제약 응답 처방을 골라 연습을 시작한다
|
||||
test("대기 큐의 제약 응답 CTA는 constrained_response 모드 쿼리로 연습 화면을 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel());
|
||||
await clickPracticeCta(page, "constrained_response");
|
||||
await expectLandedOnPractice(page, "constrained_response");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대기 큐에서 음성 재시도 처방을 골라 연습을 시작한다
|
||||
test("대기 큐의 음성 재시도 CTA는 voice_retry 모드 쿼리로 연습 화면을 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel());
|
||||
await clickPracticeCta(page, "voice_retry");
|
||||
await expectLandedOnPractice(page, "voice_retry");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대기 큐에서 난도 단계 처방을 골라 연습을 시작한다
|
||||
test("대기 큐의 난도 단계 CTA는 difficulty_ladder 모드 쿼리로 연습 화면을 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel());
|
||||
await clickPracticeCta(page, "difficulty_ladder");
|
||||
await expectLandedOnPractice(page, "difficulty_ladder");
|
||||
});
|
||||
|
||||
// usecase: 재연습 회기를 마치고 돌아온 학습자가 독립 관찰 반영 전/후 근거 횟수 비교를 확인한다
|
||||
test("재연습 복귀 리뷰에서 독립 관찰을 반영하면 반영 전 1회와 반영 후 2회가 나란히 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let observed = false;
|
||||
await routeBase(page, RETRY_SESSION_ID);
|
||||
await routeG4(page, () =>
|
||||
observed
|
||||
? g4ReadModel({
|
||||
episodes: [g4Episode(RETRY_SESSION_ID, "transfer_pending")],
|
||||
familiarDemonstrations: 2,
|
||||
})
|
||||
: g4ReadModel({ familiarDemonstrations: 1 }),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/practice/${G4_PRESCRIPTION_ID("replay")}/attempts/from-session/${RETRY_SESSION_ID}`,
|
||||
(route) => {
|
||||
observed = true;
|
||||
return fulfillJson(
|
||||
route,
|
||||
{
|
||||
submission_id: "69000000-0000-4000-8000-000000000001",
|
||||
progress: "transfer_pending",
|
||||
mastery_allowed: false,
|
||||
snapshot_id: "69000000-0000-4000-8000-000000000002",
|
||||
decision_id: "69000000-0000-4000-8000-000000000003",
|
||||
next_prescription_id: G4_PRESCRIPTION_ID("replay"),
|
||||
idempotent_replay: false,
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/learn/session/${RETRY_SESSION_ID}/review?${deliberateSearch()}`,
|
||||
);
|
||||
await expect(page.getByRole("tab", { name: "피드백" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const observation = page.locator(".dp-runtime-observation");
|
||||
await expect(observation).toBeVisible();
|
||||
await expect(observation).toContainText("반영 전");
|
||||
await expect(observation).toContainText("익숙한 장면 근거 1회");
|
||||
await expect(observation).toContainText("학습자만 실행");
|
||||
|
||||
await observation
|
||||
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
|
||||
.click();
|
||||
await expect(observation).toContainText("서버 독립 관찰 반영 완료");
|
||||
await expect(observation).toContainText("반영 후");
|
||||
await expect(observation).toContainText("익숙한 장면 근거 2회");
|
||||
await expect(observation).toContainText("원장 반영됨");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 전이 근거를 이미 기록한 학습자가 같은 버튼을 다시 눌러도 횟수가 늘지 않음을 확인한다
|
||||
test("G5 실제 전이 근거를 재확인하면 1→1회로 유지되고 중복 기록을 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const model = g5ReadModel({ withSuite: true });
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
await routeBase(page, RETRY_SESSION_ID);
|
||||
await routeG5(page, () => model);
|
||||
await page.route("**/api/calibration/transfer-executions", (route) => {
|
||||
bodies.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
const response = actualTransferResponse(bodies.length > 1);
|
||||
model.actual_executions = [response.execution];
|
||||
model.actual_transfer_assessments = [response.assessment];
|
||||
return fulfillJson(route, response, 201);
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/learn/session/${RETRY_SESSION_ID}/review?${transferSearch()}`,
|
||||
);
|
||||
const observation = page.locator(".ct-actual-transfer");
|
||||
await expect(observation).toBeVisible();
|
||||
await observation
|
||||
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
|
||||
.click();
|
||||
await expect(observation).toContainText(
|
||||
"이번 완료 회기를 실제 전이 근거로 기록했어",
|
||||
);
|
||||
await expect(observation).toContainText("0 → 1회");
|
||||
|
||||
await observation
|
||||
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
|
||||
.click();
|
||||
await expect(observation).toContainText("중복 기록은 만들지 않았어");
|
||||
await expect(observation).toContainText("1 → 1회");
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(bodies[0]).toEqual(bodies[1]);
|
||||
expect(bodies[0]).toEqual({
|
||||
original_transfer_trial_record_id: G5_TRIAL_ID,
|
||||
practice_session_id: RETRY_SESSION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
// usecase: 전이까지 확인한 학습자가 mastered 판정이 임상 숙련 주장이 아님을 게이트에서 확인한다
|
||||
test("mastered 게이트는 새 장면 전이 확인과 함께 총점·임상 숙련 합산 금지를 명시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG4(page, () =>
|
||||
g4ReadModel({
|
||||
episodes: [g4Episode(SRC_SESSION_ID, "mastered")],
|
||||
familiarDemonstrations: 1,
|
||||
}),
|
||||
);
|
||||
await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`);
|
||||
const gate = page.locator(".dp-gate--mastered");
|
||||
await expect(gate).toBeVisible();
|
||||
await expect(gate).toContainText("새 장면 전이 확인");
|
||||
await expect(gate).toContainText(
|
||||
"익숙한 장면과 새 장면에서 행동이 확인됐습니다",
|
||||
);
|
||||
await expect(gate).toContainText(
|
||||
"총점이나 임상적 숙련도 판정으로 합산하지 않습니다",
|
||||
);
|
||||
await expect(
|
||||
page.locator(".dp-card").getByText(/총점\s*[:·]\s*\d|XP\s*\d|경험치\s*\d/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 예측을 잠근 학습자는 더 이상 수정하지 못하고 독립 관찰 공개만 기다린다
|
||||
test("예측을 잠근 뒤에는 revision 폼과 잠금 UI가 사라지고 외부 관찰 대기 상태가 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG5(page, () => g5ReadModel({ locked: true }));
|
||||
await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`);
|
||||
const card = page.locator(".ct-card");
|
||||
await expect(card.getByText("예측 잠금 완료")).toBeVisible();
|
||||
await expect(card.getByText("외부 관찰을 기다리는 중")).toBeVisible();
|
||||
await expect(
|
||||
card.getByText("잠근 예측은 수정할 수 없어", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByRole("button", { name: /예측 수정 기록|첫 예측 기록/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByRole("button", { name: "이 예측 잠그기" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 아직 잠그지 않은 학습자는 독립 관찰이 가려져 있고 확인 없이는 잠그지 못한다
|
||||
test("잠금 전에는 독립 관찰이 가려지고 확인 해제 시 잠그기 버튼이 다시 닫힌다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await routeG5(page, () => g5ReadModel());
|
||||
await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`);
|
||||
const card = page.locator(".ct-card");
|
||||
await expect(card.getByText("잠금 전", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("아직 공개하지 않음")).toBeVisible();
|
||||
await expect(card.getByText("예측을 잠가야 비교 가능")).toBeVisible();
|
||||
await expect(card.getByText("공개 전", { exact: true })).toBeVisible();
|
||||
|
||||
const lockButton = card.getByRole("button", { name: "이 예측 잠그기" });
|
||||
const confirm = card.getByLabel("잠근 뒤 수정할 수 없음을 확인했어");
|
||||
await expect(lockButton).toBeDisabled();
|
||||
await confirm.check();
|
||||
await expect(lockButton).toBeEnabled();
|
||||
await confirm.uncheck();
|
||||
await expect(lockButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 전이 과제 수행을 마친 학습자가 리뷰 상단에서 같은 전이 과제로 재실행한다
|
||||
test("전이 복귀 리뷰의 재실행 버튼은 suite·trial 쿼리를 보존한 채 연습 화면으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, RETRY_SESSION_ID);
|
||||
await routeG5(page, () => g5ReadModel({ withSuite: true }));
|
||||
await page.goto(
|
||||
`/learn/session/${RETRY_SESSION_ID}/review?${transferSearch()}`,
|
||||
);
|
||||
const returnCard = page.locator(".sr-practice-return.is-transfer");
|
||||
await expect(returnCard).toBeVisible();
|
||||
await expect(returnCard).toContainText("전이 검증 결과");
|
||||
await expect(returnCard).toContainText(
|
||||
"반대근거 예측 수행 회기의 리뷰입니다.",
|
||||
);
|
||||
await expect(returnCard).toContainText("처음 보는 장면");
|
||||
await expect(returnCard).toContainText("공감적 반영");
|
||||
await expect(returnCard).toContainText("원본 회기 참조");
|
||||
|
||||
await returnCard
|
||||
.getByRole("button", { name: "같은 전이 과제로 다시 연습" })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/practice\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get("launch")).toBe("transfer");
|
||||
expect(url.searchParams.get("prescription")).toBe(G5_PRESCRIPTION_ID);
|
||||
expect(url.searchParams.get("suite")).toBe(G5_SUITE_ID);
|
||||
expect(url.searchParams.get("trial")).toBe(G5_TRIAL_ID);
|
||||
expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID);
|
||||
expect(url.searchParams.get("novelty")).toBe("unseen_transfer");
|
||||
expect(url.searchParams.get("mode")).toBe("counterevidence_forecast");
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "반대근거 예측 처방을 이어받았습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 처방 연습을 마친 학습자가 리뷰 상단 결과 카드에서 같은 처방으로 재실행한다
|
||||
test("처방 복귀 리뷰의 결과 카드는 장면·기준·출처를 표시하고 같은 처방 쿼리로 재이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, RETRY_SESSION_ID);
|
||||
await routeG4(page, () => g4ReadModel({ familiarDemonstrations: 1 }));
|
||||
await page.goto(
|
||||
`/learn/session/${RETRY_SESSION_ID}/review?${deliberateSearch()}`,
|
||||
);
|
||||
const returnCard = page.locator(".sr-practice-return.is-deliberate");
|
||||
await expect(returnCard).toBeVisible();
|
||||
await expect(returnCard).toContainText("처방 연습 결과");
|
||||
await expect(returnCard).toContainText(
|
||||
"장면 다시 보기 수행 회기의 리뷰입니다.",
|
||||
);
|
||||
await expect(returnCard).toContainText("익숙한 장면");
|
||||
await expect(returnCard).toContainText("감정 반영 후 이해 확인");
|
||||
await expect(returnCard).toContainText("원본 회기 참조");
|
||||
|
||||
await returnCard
|
||||
.getByRole("button", { name: "같은 처방으로 다시 연습" })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/practice\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get("launch")).toBe("deliberate");
|
||||
expect(url.searchParams.get("prescription")).toBe(
|
||||
G4_PRESCRIPTION_ID("replay"),
|
||||
);
|
||||
expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID);
|
||||
expect(url.searchParams.get("criterion")).toBe(G4_CRITERION_ID);
|
||||
expect(url.searchParams.get("mode")).toBe("replay");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 북마크한 전이 인계 URL로 홈 연습 화면에 직접 진입해 처방 큐 패널을 확인한다
|
||||
test("홈 연습 화면에 전이 intent로 직접 진입하면 전이 검증 패널과 과제 조건이 노출된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await page.goto(`/learn/practice?${transferSearch()}`);
|
||||
const panel = page.locator(".lh-practice-launch-intent");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText("전이 검증");
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "반대근거 예측 처방을 이어받았습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await panel.locator("summary").click();
|
||||
await expect(panel).toContainText("처음 보는 장면 실행");
|
||||
await expect(panel).toContainText("원본 회기 참조");
|
||||
await expect(panel).toContainText("공감적 반영");
|
||||
await expect(panel).not.toContainText(G5_TRIAL_ID);
|
||||
await expect(panel).not.toContainText(SRC_SESSION_ID);
|
||||
});
|
||||
|
||||
// usecase: 훼손된 처방 URL로 진입한 학습자는 일반 연습으로 우회하지 못하고 오류 안내를 받는다
|
||||
test("훼손된 launch 쿼리로 홈에 진입하면 처방 연결 오류가 뜨고 새 회기 시작이 잠긴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBase(page, SRC_SESSION_ID);
|
||||
await page.route("**/api/personas", (route) =>
|
||||
fulfillJson(route, [
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "민서(가명) · 17세 · 학교 적응 어려움",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "10대" },
|
||||
presenting_summary: "학교 적응과 무기력을 둘러싼 상담 연습",
|
||||
voice_preset: "soft-young-fem",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const tampered = new URLSearchParams({
|
||||
launch: "deliberate",
|
||||
prescription: G4_PRESCRIPTION_ID("replay"),
|
||||
source_session: SRC_SESSION_ID,
|
||||
criterion: G4_CRITERION_ID,
|
||||
novelty: "familiar",
|
||||
mode: "teleport",
|
||||
});
|
||||
await page.goto(`/learn/practice?${tampered.toString()}`);
|
||||
const alert = page.locator(".lh-voice-practice-intent.is-error");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText(
|
||||
"연습 처방의 출처를 검증할 수 없습니다.",
|
||||
);
|
||||
await expect(alert).toContainText("일반 연습으로 바꾸지 않았습니다.");
|
||||
await page.getByRole("option", { name: /P1/ }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "새 회기 시작" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
698
apps/web/e2e/uc-review-alliance-pulse.spec.ts
Normal file
698
apps/web/e2e/uc-review-alliance-pulse.spec.ts
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
/**
|
||||
* uc-review-alliance-pulse.spec.ts — 치료 동맹 펄스(pre/mid/post · 3축 · 관점차) 유스케이스 여정
|
||||
*
|
||||
* 목적: 학습자가 회기 전(pre)·중(mid)·후(post) 시점에 goal/task/bond 세 축을 스스로
|
||||
* 잠그고, AI 관점(내담자/관찰자)과의 차이를 근거 발화와 함께 확인하는 실제 사용 여정을
|
||||
* route fixture만으로 검증한다.
|
||||
*
|
||||
* 근거:
|
||||
* - apps/web/src/pages/session/AllianceCheckpointPrompt.tsx (pre fail-closed 게이트, mid 비차단)
|
||||
* - apps/web/src/pages/session-review/AlliancePulseCard.tsx (post 잠금·관점 비교·근거 점프)
|
||||
* - apps/web/src/pages/session-review/alliancePulseApi.ts (엔드포인트·응답 타입)
|
||||
* - apps/web/src/pages/session-review/alliance-pulse.css (≤360px에서 1~5 척도 3+2 배열)
|
||||
* - e2e/session-review-fixture.ts (리뷰 응답·prepost 빌더)
|
||||
*
|
||||
* 중복 회피: e2e/alliance-pulse.spec.ts 는 post 행복 경로 잠금→공개, 320px 무스크롤·44px
|
||||
* 타깃, 관찰자 근거 점프, 교수자 판정을 다룬다. 이 파일은 실패·경합(fail-closed 500,
|
||||
* 409 동기화, 422 거절), 미완성 초안의 제출 차단, mid 비차단 접기, 3+2 줄 배열 자체,
|
||||
* 공개 후 수정 불가를 다른 각도에서 다룬다.
|
||||
*
|
||||
* 실제 AI 엔진 턴 생성 없음 — 모든 API·SSE 는 route fixture 로 응답한다.
|
||||
*/
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
|
||||
const SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const LEARNER_TEXT = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||
const CLIENT_REPLY = "괜찮아요. 천천히 말해볼게요.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill(jsonRoute(body, status));
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedLearner(page: Page) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-000000000101",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "E2E Learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["e2e-hanshin"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "E2E Learner",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
interface PulseItem {
|
||||
pulse_id: string;
|
||||
checkpoint: "pre" | "mid" | "post";
|
||||
status: string;
|
||||
learner_locked_at: string | null;
|
||||
revealed_at: string | null;
|
||||
error_code: string | null;
|
||||
self_scores: Record<"goal" | "task" | "bond", number> | null;
|
||||
measurements: unknown[];
|
||||
}
|
||||
|
||||
function lockedPulse(
|
||||
checkpoint: "pre" | "mid" | "post",
|
||||
scores: Record<"goal" | "task" | "bond", number> = { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
overrides: Partial<PulseItem> = {},
|
||||
): PulseItem {
|
||||
return {
|
||||
pulse_id: `pulse-${checkpoint}-1`,
|
||||
checkpoint,
|
||||
status: "awaiting_agents",
|
||||
learner_locked_at: "2026-08-06T08:59:00Z",
|
||||
revealed_at: null,
|
||||
error_code: null,
|
||||
self_scores: scores,
|
||||
measurements: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionFixtureOptions {
|
||||
initialPulses?: PulseItem[];
|
||||
/**
|
||||
* true면 restorePulseLedger() 호출 전까지 원장 GET이 500으로 실패한다.
|
||||
* (React StrictMode dev 이중 mount가 GET을 한 번 더 보내므로, 1회성 실패
|
||||
* 카운트는 사용자에게 보이기 전에 소진된다 — 명시 복구 스위치로 고정한다.)
|
||||
*/
|
||||
pulseGetFailing?: boolean;
|
||||
pulsePostStatus?: number; // default 202
|
||||
pulsePostConflictLedger?: PulseItem[]; // ledger returned by GET after a 409 POST
|
||||
}
|
||||
|
||||
async function routeLiveSession(page: Page, options: SessionFixtureOptions = {}) {
|
||||
const pulseItems: PulseItem[] = [...(options.initialPulses ?? [])];
|
||||
const pulsePosts: Array<{ checkpoint?: string; scores?: Record<string, number> }> = [];
|
||||
let pulseGetFailing = options.pulseGetFailing ?? false;
|
||||
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
fulfillJson(route, {
|
||||
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", (route) =>
|
||||
fulfillJson(route, { available: false, reason: "fixture" }),
|
||||
);
|
||||
|
||||
await page.route("**/api/personas", (route) =>
|
||||
fulfillJson(route, [
|
||||
{
|
||||
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 fulfillJson(
|
||||
route,
|
||||
{
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-alliance-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.21,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
fulfillJson(route, {
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-alliance-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.21,
|
||||
started_at: "2026-08-06T08:00:00Z",
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
checkpoint?: "pre" | "mid" | "post";
|
||||
scores?: Record<string, number>;
|
||||
};
|
||||
pulsePosts.push(body);
|
||||
const postStatus = options.pulsePostStatus ?? 202;
|
||||
if (postStatus === 409) {
|
||||
pulseItems.push(...(options.pulsePostConflictLedger ?? []));
|
||||
await fulfillJson(route, { detail: "이미 잠긴 자기점검이 있습니다." }, 409);
|
||||
return;
|
||||
}
|
||||
if (postStatus >= 400) {
|
||||
await fulfillJson(route, { detail: "checkpoint window closed" }, postStatus);
|
||||
return;
|
||||
}
|
||||
const pulseId = `pulse-${body.checkpoint ?? "pre"}-created`;
|
||||
pulseItems.push(
|
||||
lockedPulse(body.checkpoint ?? "pre", (body.scores ?? null) as never, {
|
||||
pulse_id: pulseId,
|
||||
}),
|
||||
);
|
||||
await fulfillJson(route, { pulse_id: pulseId, status: "awaiting_agents" }, 202);
|
||||
return;
|
||||
}
|
||||
if (pulseGetFailing) {
|
||||
await fulfillJson(route, { detail: "alliance ledger unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { items: pulseItems });
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: [
|
||||
"event: token",
|
||||
`data: ${CLIENT_REPLY}`,
|
||||
"",
|
||||
"event: done",
|
||||
`data: ${JSON.stringify({
|
||||
session_id: SESSION_ID,
|
||||
stage: "탐색",
|
||||
effective_openness: 0.42,
|
||||
turn_seq: 1,
|
||||
safety_flagged: false,
|
||||
})}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
pulsePosts,
|
||||
pulseItems,
|
||||
restorePulseLedger: () => {
|
||||
pulseGetFailing = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startSession(page: Page) {
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
}
|
||||
|
||||
async function completeOneExchange(page: Page) {
|
||||
const composer = page.getByLabel("학습자 발화 입력");
|
||||
await expect(composer).toBeEnabled();
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
|
||||
}
|
||||
|
||||
async function pickCheckpointScore(
|
||||
page: Page,
|
||||
axis: "목표" | "과업" | "유대",
|
||||
scoreName: string,
|
||||
) {
|
||||
const group = page.getByRole("group", { name: new RegExp(`^${axis}`) });
|
||||
await group.getByRole("radio", { name: scoreName }).check();
|
||||
}
|
||||
|
||||
// ── 리뷰(post) fixture ─────────────────────────────────────────────
|
||||
|
||||
type Dimension = "goal" | "task" | "bond";
|
||||
type Perspective = "client_agent_report" | "independent_observer";
|
||||
|
||||
const AI_VALUES: Record<Perspective, Record<Dimension, number>> = {
|
||||
client_agent_report: { goal: 0.4, task: 0.6, bond: 0.9 },
|
||||
independent_observer: { goal: 0.2, task: 0.55, bond: 0.65 },
|
||||
};
|
||||
|
||||
function revealedMeasurements() {
|
||||
return (Object.keys(AI_VALUES) as Perspective[]).flatMap((perspective) =>
|
||||
(["goal", "task", "bond"] as Dimension[]).map((dimension) => ({
|
||||
measurement_id: `${perspective}-${dimension}`,
|
||||
dimension,
|
||||
perspective,
|
||||
source_kind: "model_inference",
|
||||
value: AI_VALUES[perspective][dimension],
|
||||
confidence: 0.8,
|
||||
status: "ready",
|
||||
error_code: null,
|
||||
rationale:
|
||||
dimension === "bond"
|
||||
? "방어가 낮아진 마지막 발화를 근거로 판단했습니다."
|
||||
: "목표와 과업을 함께 확인한 발화를 근거로 판단했습니다.",
|
||||
evidence: [
|
||||
{
|
||||
turn_id: "t6",
|
||||
seq: 6,
|
||||
speaker: "client",
|
||||
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
||||
},
|
||||
],
|
||||
created_at: "2026-08-06T09:00:00Z",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function revealedPostPulse(): PulseItem {
|
||||
return lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, {
|
||||
pulse_id: "pulse-post-revealed",
|
||||
status: "revealed",
|
||||
revealed_at: "2026-08-06T09:00:05Z",
|
||||
measurements: revealedMeasurements(),
|
||||
});
|
||||
}
|
||||
|
||||
async function routeReviewPage(page: Page) {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routeFilledSessionReview(page);
|
||||
await routePrepostMeasures(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리뷰 척도 선택. AlliancePulseCard의 라디오 input은 clip/clip-path로 시각
|
||||
* 숨김이라(hit-test에서 제외) input.check()의 포인터가 label에 가로막힌다.
|
||||
* 실제 사용자와 같은 계약대로 .ap-scale__option(label)을 클릭하고, 상태
|
||||
* 변화(checked)는 그대로 어설션한다.
|
||||
*/
|
||||
async function pickReviewScore(page: Page, groupName: string, scoreName: string) {
|
||||
const group = page.getByRole("radiogroup", { name: groupName });
|
||||
const radio = group.getByRole("radio", { name: scoreName });
|
||||
await group
|
||||
.locator(".ap-scale__option")
|
||||
.filter({ has: page.getByRole("radio", { name: scoreName }) })
|
||||
.click();
|
||||
await expect(radio).toBeChecked();
|
||||
}
|
||||
|
||||
// ── pre: 회기 진입 전 fail-closed 잠금 ─────────────────────────────
|
||||
|
||||
test.describe("UC 치료 동맹 펄스 — 회기 전 자기점검(pre)", () => {
|
||||
// usecase: 학습자가 회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다
|
||||
test("회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLiveSession(page);
|
||||
await startSession(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
|
||||
// 제출 전부터 "수정 불가" 계약을 고지한다.
|
||||
await expect(
|
||||
page.getByText("제출한 판단은 수정하지 않고, 세 관점은 모두 준비된 뒤 공개합니다."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다
|
||||
test("세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다", async ({ page }) => {
|
||||
await routeLiveSession(page);
|
||||
await startSession(page);
|
||||
|
||||
const progress = page.locator(".sx-alliance-checkpoint__progress");
|
||||
await expect(progress).toContainText("0/3");
|
||||
const submit = page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await pickCheckpointScore(page, "목표", "3 보통이다");
|
||||
await pickCheckpointScore(page, "과업", "3 보통이다");
|
||||
await expect(progress).toContainText("2/3");
|
||||
await expect(progress).toContainText("축 선택함");
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await pickCheckpointScore(page, "유대", "4 대체로 그렇다");
|
||||
await expect(progress).toContainText("3/3");
|
||||
await expect(progress).toContainText("선택 완료");
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 자기점검 원장 확인이 실패한 학습자는 첫 발화가 열리지 않고, 다시 확인으로 복구한다
|
||||
test("자기점검 원장 확인이 실패하면 첫 발화를 열지 않고 다시 확인으로 복구한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const api = await routeLiveSession(page, {
|
||||
pulseGetFailing: true,
|
||||
initialPulses: [lockedPulse("pre")],
|
||||
});
|
||||
await startSession(page);
|
||||
|
||||
// fail-closed: 원장 상태를 모르면 발화를 열지 않는다.
|
||||
await expect(page.getByText("자기점검 상태를 확인하지 못했습니다")).toBeVisible();
|
||||
await expect(page.getByText("첫 발화는 상태 확인 뒤에 열립니다.")).toBeVisible();
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
|
||||
|
||||
// 서버가 복구된 뒤 "다시 확인"으로 원장을 재조회하면 게이트가 열린다.
|
||||
api.restorePulseLedger();
|
||||
await page.getByRole("button", { name: "다시 확인" }).click();
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 이미 pre를 잠근 학습자가 회기에 돌아오면 점검을 다시 요구받지 않는다
|
||||
test("이미 pre가 잠긴 회기로 돌아오면 점검을 다시 요구하지 않고 바로 발화를 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
||||
await startSession(page);
|
||||
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }),
|
||||
).toHaveCount(0);
|
||||
// pre 잠금 뒤 mid 이전에는 점검 UI가 회기 본문을 밀어내지 않는다.
|
||||
await expect(page.locator(".sx-alliance-checkpoint")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 다른 탭에서 이미 잠근 pre와 겹친 학습자는 서버 원장과 동기화되어 첫 발화가 열린다
|
||||
test("pre 잠금이 409로 겹치면 서버 원장과 동기화해 첫 발화를 연다", async ({ page }) => {
|
||||
const api = await routeLiveSession(page, {
|
||||
pulsePostStatus: 409,
|
||||
pulsePostConflictLedger: [lockedPulse("pre", { goal: 0.25, task: 0.5, bond: 0.75 })],
|
||||
});
|
||||
await startSession(page);
|
||||
|
||||
await pickCheckpointScore(page, "목표", "3 보통이다");
|
||||
await pickCheckpointScore(page, "과업", "3 보통이다");
|
||||
await pickCheckpointScore(page, "유대", "3 보통이다");
|
||||
await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click();
|
||||
|
||||
// 409 → 원장 재조회 → 게이트 해제(중복 생성 없음).
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
expect(api.pulsePosts.map((item) => item.checkpoint)).toEqual(["pre"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── mid: 비차단 진행 ──────────────────────────────────────────────
|
||||
|
||||
test.describe("UC 치료 동맹 펄스 — 회기 중 자기점검(mid)", () => {
|
||||
// usecase: 대화가 한 왕복 쌓인 학습자에게 30초 점검이 떠도 대화 입력은 계속 열려 있다
|
||||
test("한 왕복이 쌓이면 30초 점검 배지가 떠도 대화 입력은 계속 열려 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
||||
await startSession(page);
|
||||
await completeOneExchange(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("한 번의 대화 왕복이 쌓였습니다. 현재 판단을 남길 수 있어요."),
|
||||
).toBeVisible();
|
||||
// mid 는 pre 와 달리 비차단: 배지가 떠 있어도 발화 입력이 잠기지 않는다.
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다
|
||||
test("30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다", async ({ page }) => {
|
||||
const api = await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
||||
await startSession(page);
|
||||
await completeOneExchange(page);
|
||||
|
||||
await page.getByRole("button", { name: "30초 점검" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "대화 먼저 이어가기" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
|
||||
// 접기는 제출이 아니다 — 원장에 아무것도 쓰지 않는다.
|
||||
expect(api.pulsePosts).toHaveLength(0);
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 회기 상태가 바뀌어 mid 잠금이 422로 거절된 학습자는 사유를 그대로 본다
|
||||
test("회기 상태가 바뀌어 mid 잠금이 422로 거절되면 사유를 그대로 본다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLiveSession(page, {
|
||||
initialPulses: [lockedPulse("pre")],
|
||||
pulsePostStatus: 422,
|
||||
});
|
||||
await startSession(page);
|
||||
await completeOneExchange(page);
|
||||
|
||||
await page.getByRole("button", { name: "30초 점검" }).click();
|
||||
await pickCheckpointScore(page, "목표", "4 대체로 그렇다");
|
||||
await pickCheckpointScore(page, "과업", "4 대체로 그렇다");
|
||||
await pickCheckpointScore(page, "유대", "4 대체로 그렇다");
|
||||
await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("회기 진행 상태가 바뀌어 이 시점의 점검을 잠글 수 없습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── post: 리뷰에서 잠금·공개·수정 불가 ─────────────────────────────
|
||||
|
||||
test.describe("UC 치료 동맹 펄스 — 리뷰(post)", () => {
|
||||
// usecase: 리뷰에서 근거 장면 없이 세 축만 고른 학습자는 post 판단을 잠글 수 없다
|
||||
test("리뷰에서 근거 장면 없이 세 축만 고르면 post 판단을 잠글 수 없다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeReviewPage(page);
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
fulfillJson(route, { items: [] }),
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다");
|
||||
await pickReviewScore(page, "과업 합의 평가", "3 보통이다");
|
||||
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
||||
|
||||
// 저장 발화가 있는 회기에서는 근거 장면 없이 잠글 수 없다.
|
||||
const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await expect(page.getByText("1개 선택")).toBeVisible();
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 다른 탭에서 이미 잠근 post와 겹친 학습자는 서버에 저장된 자기평가를 그대로 본다
|
||||
test("post 잠금이 409로 겹치면 서버에 저장된 자기평가를 그대로 본다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeReviewPage(page);
|
||||
let conflicted = false;
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
conflicted = true;
|
||||
await fulfillJson(route, { detail: "이미 잠긴 자기평가가 있습니다." }, 409);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
items: conflicted
|
||||
? [lockedPulse("post", { goal: 0.25, task: 0.5, bond: 0.75 })]
|
||||
: [],
|
||||
});
|
||||
});
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
await pickReviewScore(page, "목표 합의 평가", "5 매우 그렇다");
|
||||
await pickReviewScore(page, "과업 합의 평가", "5 매우 그렇다");
|
||||
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
||||
|
||||
// 내가 방금 고른 5·5·5가 아니라 서버 원장의 2·3·4가 진실이다.
|
||||
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||
const locked = page.locator(".ap-locked-scores");
|
||||
await expect(locked).toContainText("25점");
|
||||
await expect(locked).toContainText("50점");
|
||||
await expect(locked).toContainText("75점");
|
||||
});
|
||||
|
||||
// usecase: AI 분석이 실패한 학습자도 자신의 세 축 판단은 보존되고, 지어낸 점수를 보지 않는다
|
||||
test("AI 분석이 실패해도 내 세 축 판단은 보존되고 점수를 지어내지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeReviewPage(page);
|
||||
const errorPulse = lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, {
|
||||
status: "error",
|
||||
error_code: "agents_failed",
|
||||
});
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
fulfillJson(route, { items: [errorPulse] }),
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
await expect(page.getByText("분석 보류", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("내 판단은 보존됐습니다")).toBeVisible();
|
||||
|
||||
// goal/task/bond 세 축이 각각 자기 점수로 표시된다.
|
||||
const locked = page.locator(".ap-locked-scores");
|
||||
await expect(locked.getByText("목표", { exact: true })).toBeVisible();
|
||||
await expect(locked.getByText("과업", { exact: true })).toBeVisible();
|
||||
await expect(locked.getByText("유대", { exact: true })).toBeVisible();
|
||||
await expect(locked).toContainText("75점");
|
||||
await expect(locked).toContainText("50점");
|
||||
await expect(locked).toContainText("100점");
|
||||
|
||||
// 준비되지 않은 AI 관점을 추정해 그리지 않는다.
|
||||
await expect(page.getByLabel("치료 동맹 관점 비교")).toHaveCount(0);
|
||||
await expect(page.locator(".ap-waiting__skeleton")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 공개된 비교 뷰에서 자기·관찰자 점수 차이를 확인하고 근거 발화로 점프한다
|
||||
test("공개된 비교 뷰에서 자기·관찰자 점수 차이를 보고 근거 발화로 점프한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeReviewPage(page);
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
fulfillJson(route, { items: [revealedPostPulse()] }),
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
const comparison = page.getByLabel("치료 동맹 관점 비교");
|
||||
await expect(comparison).toBeVisible();
|
||||
|
||||
// goal 축: 내 판단 75점 vs 내담자 40점 vs 관찰자 20점 — 관점차가 그대로 병치된다.
|
||||
const goalRow = comparison.locator('section[aria-labelledby="ap-dimension-goal"]');
|
||||
await expect(goalRow).toContainText("75점");
|
||||
await expect(goalRow).toContainText("40점");
|
||||
await expect(goalRow).toContainText("20점");
|
||||
// 세 축이 각각 독립된 행으로 표시된다.
|
||||
await expect(
|
||||
comparison.locator('section[aria-labelledby="ap-dimension-task"]'),
|
||||
).toContainText("55점");
|
||||
await expect(
|
||||
comparison.locator('section[aria-labelledby="ap-dimension-bond"]'),
|
||||
).toContainText("90점");
|
||||
|
||||
// bond 축 내담자 관점 근거에서 축어록으로 점프한다.
|
||||
const bondRow = comparison.locator('section[aria-labelledby="ap-dimension-bond"]');
|
||||
await bondRow.locator(".ap-measurement-evidence").first().getByText("근거 1개").click();
|
||||
await bondRow
|
||||
.getByRole("button", { name: /6번째 발화/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.locator(".sr-turn--active")).toContainText("기억에 남아요");
|
||||
});
|
||||
|
||||
// usecase: 관점이 공개된 뒤의 학습자에게는 자기평가를 고칠 입력이 남아 있지 않다
|
||||
test("관점 공개 뒤에는 자기평가를 고칠 입력이 남아 있지 않다", async ({ page }) => {
|
||||
await routeReviewPage(page);
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
fulfillJson(route, { items: [revealedPostPulse()] }),
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible();
|
||||
// 학습자 뷰에는 다시 고를 라디오도, 잠금 버튼도 남지 않는다.
|
||||
const card = page.locator(".ap-card");
|
||||
await expect(card.getByRole("radio")).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByRole("button", { name: "내 판단 잠그고 관점 비교" }),
|
||||
).toHaveCount(0);
|
||||
// 내 열은 "잠긴 자기평가" 출처로만 남는다.
|
||||
await expect(card.locator("small:visible", { hasText: "잠긴 자기평가" }).first()).toBeVisible();
|
||||
await expect(
|
||||
card.getByText("먼저 기록한 자기평가").first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 320px 폰에서 리뷰를 여는 학습자에게 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다
|
||||
test("320px에서 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
test.info().project.name !== "chromium-mobile",
|
||||
"초소형 폭 배열 검증은 mobile 프로젝트에서만 수행",
|
||||
);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await routeReviewPage(page);
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
fulfillJson(route, { items: [] }),
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
const scale = page.locator(".ap-scale").first();
|
||||
await expect(scale).toBeVisible();
|
||||
const options = scale.locator(".ap-scale__option");
|
||||
await expect(options).toHaveCount(5);
|
||||
|
||||
const boxes = await options.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { top: rect.top, left: rect.left, right: rect.right };
|
||||
}),
|
||||
);
|
||||
// 첫 줄: 1·2·3 이 같은 행.
|
||||
expect(Math.abs(boxes[1].top - boxes[0].top)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(boxes[2].top - boxes[0].top)).toBeLessThanOrEqual(2);
|
||||
// 둘째 줄: 4·5 가 같은 행으로 내려간다.
|
||||
expect(boxes[3].top).toBeGreaterThan(boxes[0].top + 10);
|
||||
expect(Math.abs(boxes[4].top - boxes[3].top)).toBeLessThanOrEqual(2);
|
||||
// 4번 칸은 2번 그리드 컬럼에서 시작해 가운데 정렬된다.
|
||||
expect(boxes[3].left).toBeGreaterThan(boxes[0].left + 10);
|
||||
// 모든 칸이 화면 안에 남는다.
|
||||
for (const box of boxes) {
|
||||
expect(box.right).toBeLessThanOrEqual(320);
|
||||
}
|
||||
});
|
||||
});
|
||||
654
apps/web/e2e/uc-review-rupture-repair.spec.ts
Normal file
654
apps/web/e2e/uc-review-rupture-repair.spec.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
/**
|
||||
* uc-review-rupture-repair.spec.ts — 균열·수선(rupture/repair) 리뷰 여정 유스케이스 스펙.
|
||||
*
|
||||
* 목적: 학습자·교수자가 세션 리뷰의 "피드백" 탭에서 균열과 수선 원장(RuptureRepairCard)을
|
||||
* 읽고 행동하는 실제 여정을 검증한다 — 여러 균열 정렬, missed/partial/resolved 상태 뱃지,
|
||||
* 빠른 경고 vs 깊은 재조정 복구 분기 비교, 장면 재생(해당 턴 점프), 교수자 정정의
|
||||
* append-only 표시, 균열 없음 빈 상태, 오류 후 재시도.
|
||||
*
|
||||
* 근거: apps/web/src/pages/session-review/RuptureRepairCard.tsx 의 실제 한국어 카피·
|
||||
* role/label 계약과 apps/web/src/pages/session-review/ruptureRepairApi.ts 의 wire DTO.
|
||||
* fixture 형태는 e2e/rupture-repair.spec.ts 를 따르되, 그 스펙이 이미 커버한 시나리오
|
||||
* (학습자 전체 뷰, 404 학습자 빈 상태, 정정 제출·idempotent 재시도)는 반복하지 않는다.
|
||||
*
|
||||
* 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
|
||||
*/
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import type {
|
||||
RuptureEpisode,
|
||||
RuptureObservation,
|
||||
RuptureReconciliation,
|
||||
RuptureRepairReadModel,
|
||||
RuptureSafetyReference,
|
||||
} from "../src/pages/session-review/ruptureRepairApi";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
filledReviewResponse,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
type ReviewRole = "learner" | "teacher";
|
||||
|
||||
const TURN_UUIDS = [
|
||||
"40000000-0000-4000-8000-000000000001",
|
||||
"40000000-0000-4000-8000-000000000002",
|
||||
"40000000-0000-4000-8000-000000000003",
|
||||
"40000000-0000-4000-8000-000000000004",
|
||||
"40000000-0000-4000-8000-000000000005",
|
||||
"40000000-0000-4000-8000-000000000006",
|
||||
];
|
||||
const UNKNOWN_TURN_UUID = "49999999-0000-4000-8000-000000000099";
|
||||
const MODEL_RUN_ID = "43000000-0000-4000-8000-000000000001";
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
let episodeSeq = 0;
|
||||
function nextEpisodeId(): string {
|
||||
episodeSeq += 1;
|
||||
return `41000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
let observationSeq = 0;
|
||||
function makeObservation(
|
||||
episodeId: string,
|
||||
overrides: Partial<RuptureObservation> = {},
|
||||
): RuptureObservation {
|
||||
observationSeq += 1;
|
||||
return {
|
||||
observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
|
||||
episode_id: episodeId,
|
||||
sequence_no: 1,
|
||||
event_kind: "rupture.detected",
|
||||
from_state: null,
|
||||
to_state: "onset",
|
||||
rupture_type: "withdrawal",
|
||||
source_kind: "observed_runtime",
|
||||
perspective: "runtime_observation",
|
||||
ai_view: "evaluator",
|
||||
confidence: 0.8,
|
||||
uncertainty: 0.2,
|
||||
evidence_turn_ids: [TURN_UUIDS[1]],
|
||||
counterevidence: [],
|
||||
model_run_id: null,
|
||||
supersedes_observation_id: null,
|
||||
correction_reason: null,
|
||||
created_at: "2026-08-06T09:10:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReconciliation(
|
||||
episodeId: string,
|
||||
overrides: Partial<RuptureReconciliation> = {},
|
||||
): RuptureReconciliation {
|
||||
return {
|
||||
revision_id: `45000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`,
|
||||
episode_id: episodeId,
|
||||
revision_no: 1,
|
||||
supersedes_revision_id: null,
|
||||
fast_warning_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
|
||||
deep_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
|
||||
fast_warning_id: "fast-warning-9",
|
||||
provisional_status: "missed",
|
||||
deep_status: "partial",
|
||||
disposition: "superseded_partial",
|
||||
uncertainty: 0.22,
|
||||
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
|
||||
counterevidence: [],
|
||||
model_run_id: MODEL_RUN_ID,
|
||||
created_at: "2026-08-06T09:16:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSafetyReference(
|
||||
episodeId: string,
|
||||
overrides: Partial<RuptureSafetyReference> = {},
|
||||
): RuptureSafetyReference {
|
||||
return {
|
||||
episode_id: episodeId,
|
||||
safety_event_id: 71,
|
||||
turn_id: TURN_UUIDS[4],
|
||||
ko_risk_level: 2,
|
||||
escalated: true,
|
||||
created_at: "2026-08-06T09:12:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEpisode(overrides: Partial<RuptureEpisode> = {}): RuptureEpisode {
|
||||
const episodeId = (overrides.episode_id as string | undefined) ?? nextEpisodeId();
|
||||
return {
|
||||
episode_id: episodeId,
|
||||
session_id: FILLED_REVIEW_SESSION_ID,
|
||||
case_id: "44000000-0000-4000-8000-000000000001",
|
||||
learner_id: "44000000-0000-4000-8000-000000000002",
|
||||
episode_key: "withdrawal-after-premature-advice",
|
||||
created_at: "2026-08-06T09:10:00Z",
|
||||
rupture_type: "withdrawal",
|
||||
current_status: "partial",
|
||||
status_source: "lifecycle_event",
|
||||
observations: [makeObservation(episodeId)],
|
||||
reconciliation_revisions: [],
|
||||
safety_references: [],
|
||||
...overrides,
|
||||
...(overrides.episode_id ? {} : { episode_id: episodeId }),
|
||||
};
|
||||
}
|
||||
|
||||
function readModel(
|
||||
role: ReviewRole,
|
||||
episodes: RuptureEpisode[],
|
||||
): RuptureRepairReadModel {
|
||||
return {
|
||||
session_id: FILLED_REVIEW_SESSION_ID,
|
||||
requested_view: role === "teacher" ? "supervisor" : "counselor",
|
||||
clinical_claim_allowed: false,
|
||||
episodes,
|
||||
};
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
fulfillJson(route, { detail: "not part of this focused fixture" }, 404),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeReviewUser(page: Page, role: ReviewRole) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
fulfillJson(route, {
|
||||
user_id:
|
||||
role === "teacher"
|
||||
? "00000000-0000-0000-0000-000000000202"
|
||||
: "00000000-0000-0000-0000-000000000101",
|
||||
email: `${role}@hs.ac.kr`,
|
||||
role,
|
||||
display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["e2e-hanshin"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeReviewPage(page: Page, role: ReviewRole) {
|
||||
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
|
||||
review.turns = review.turns.map((turn, index) => ({
|
||||
...turn,
|
||||
turn_id: TURN_UUIDS[index] ?? null,
|
||||
}));
|
||||
if (role === "teacher") {
|
||||
review.teacherReview = {
|
||||
status: "viewed",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: "00000000-0000-0000-0000-000000000202",
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
};
|
||||
}
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
|
||||
fulfillJson(route, review),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
|
||||
(route) => fulfillJson(route, { items: [] }),
|
||||
);
|
||||
await routePrepostMeasures(page);
|
||||
}
|
||||
|
||||
async function prepareReview(page: Page, role: ReviewRole) {
|
||||
await routeUnmockedApi(page);
|
||||
await routeReviewUser(page, role);
|
||||
await routeReviewPage(page, role);
|
||||
}
|
||||
|
||||
async function routeRuptures(
|
||||
page: Page,
|
||||
handler: (route: Route) => Promise<void>,
|
||||
) {
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`,
|
||||
handler,
|
||||
);
|
||||
}
|
||||
|
||||
async function openFeedback(page: Page, role: ReviewRole) {
|
||||
const root = role === "teacher" ? "/teach/session" : "/learn/session";
|
||||
await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
return page.locator(".rr-card");
|
||||
}
|
||||
|
||||
function tripleEpisodes(): RuptureEpisode[] {
|
||||
return [
|
||||
makeEpisode({
|
||||
episode_key: "withdrawal-early",
|
||||
rupture_type: "withdrawal",
|
||||
current_status: "missed",
|
||||
}),
|
||||
makeEpisode({
|
||||
episode_key: "confrontation-mid",
|
||||
rupture_type: "confrontation",
|
||||
current_status: "partial",
|
||||
}),
|
||||
makeEpisode({
|
||||
episode_key: "goal-mismatch-late",
|
||||
rupture_type: "goal_mismatch",
|
||||
current_status: "resolved",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
test.describe("UC 균열·수선 리뷰 여정", () => {
|
||||
test.beforeEach(() => {
|
||||
episodeSeq = 0;
|
||||
observationSeq = 0;
|
||||
});
|
||||
|
||||
// usecase: 학습자가 피드백 탭을 열어 여러 균열 장면이 원장에 온 순서 그대로 나열된 것을 본다
|
||||
test("여러 균열 에피소드가 fixture 순서대로 나열된다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", tripleEpisodes())),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const episodes = card.locator(".rr-episode");
|
||||
await expect(episodes).toHaveCount(3);
|
||||
await expect(episodes.nth(0).getByRole("heading", { name: "철수형 균열" })).toBeVisible();
|
||||
await expect(episodes.nth(1).getByRole("heading", { name: "대립형 균열" })).toBeVisible();
|
||||
await expect(episodes.nth(2).getByRole("heading", { name: "목표 불일치" })).toBeVisible();
|
||||
await expect(episodes.nth(0)).toContainText("withdrawal-early");
|
||||
await expect(episodes.nth(1)).toContainText("confrontation-mid");
|
||||
await expect(episodes.nth(2)).toContainText("goal-mismatch-late");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 장면마다 붙은 상태 뱃지로 수선 놓침·부분 수선·수선 확인을 구분해 읽는다
|
||||
test("missed·partial·resolved 상태 뱃지를 장면별로 구분해 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", tripleEpisodes())),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const episodes = card.locator(".rr-episode");
|
||||
await expect(
|
||||
episodes.nth(0).locator(".rr-episode__badges").getByText("수선 놓침", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
episodes.nth(1).locator(".rr-episode__badges").getByText("부분 수선", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
episodes.nth(2).locator(".rr-episode__badges").getByText("수선 확인", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 아직 유형·상태 판정이 없는 장면을 열어 판정 대기 상태임을 확인한다
|
||||
test("유형·상태 미판정 에피소드는 판정 대기로 표시된다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const pending = makeEpisode({
|
||||
rupture_type: null,
|
||||
current_status: null,
|
||||
observations: [],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [pending])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(card.getByRole("heading", { name: "유형 판정 대기" })).toBeVisible();
|
||||
await expect(
|
||||
card.locator(".rr-episode__badges").getByText("상태 대기", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(card.locator(".rr-current")).toContainText("판정 대기");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 현재 상태 요약에서 관찰 이력 출처와 불확실성 미기록(자료 없음)을 읽는다
|
||||
test("관찰 이력 출처 요약에서 불확실성 미기록은 자료 없음으로 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "recognized",
|
||||
status_source: "lifecycle_event",
|
||||
observations: [
|
||||
makeObservation(episodeId, {
|
||||
event_kind: "rupture.recognized",
|
||||
to_state: "recognized",
|
||||
uncertainty: null,
|
||||
confidence: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const current = card.locator(".rr-current");
|
||||
await expect(current.getByText("균열 인식", { exact: true })).toBeVisible();
|
||||
await expect(current.getByText("자료 없음", { exact: true })).toBeVisible();
|
||||
await expect(current.getByText("관찰 이력", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 복구 분기 비교 뷰에서 빠른 경고 판정과 깊은 재조정 판정을 나란히 대조한다
|
||||
test("복구 분기 비교 뷰가 빠른 경고와 깊은 재조정 판정을 나란히 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "resolved",
|
||||
status_source: "deep_reconciliation",
|
||||
observations: [makeObservation(episodeId)],
|
||||
reconciliation_revisions: [
|
||||
makeReconciliation(episodeId, {
|
||||
provisional_status: "missed",
|
||||
deep_status: "resolved",
|
||||
disposition: "superseded_resolved",
|
||||
fast_warning_id: "fast-warning-9",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처");
|
||||
await expect(rail.getByText("빠른 경고", { exact: true })).toBeVisible();
|
||||
await expect(rail).toContainText("수선 놓침 · fast-warning-9");
|
||||
await expect(rail.getByText("깊은 재조정", { exact: true })).toBeVisible();
|
||||
await expect(rail).toContainText("수선 확인으로 갱신 · 수선 확인");
|
||||
await expect(rail.locator("code")).toContainText("run 43000000…000001");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 기각된 빠른 경고를 열어 경고 기각 처분과 판정 제외 상태를 확인한다
|
||||
test("기각된 빠른 경고는 경고 기각 처분으로 표시된다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "not_applicable",
|
||||
status_source: "deep_reconciliation",
|
||||
observations: [makeObservation(episodeId)],
|
||||
reconciliation_revisions: [
|
||||
makeReconciliation(episodeId, {
|
||||
provisional_status: "onset",
|
||||
deep_status: "not_applicable",
|
||||
disposition: "dismissed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(
|
||||
card.locator(".rr-episode__badges").getByText("판정 제외", { exact: true }),
|
||||
).toBeVisible();
|
||||
const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처");
|
||||
await expect(rail).toContainText("경고 기각 · 판정 제외");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 안전 원장의 근거 발화 버튼으로 해당 장면(턴)으로 점프해 다시 본다
|
||||
test("안전 원장 근거 발화 버튼으로 축어록 해당 턴에 점프한다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
observations: [makeObservation(episodeId)],
|
||||
safety_references: [makeSafetyReference(episodeId, { turn_id: TURN_UUIDS[4] })],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const safety = card.locator(".rr-safety");
|
||||
await expect(safety.getByText("안전 사건 #71")).toBeVisible();
|
||||
await expect(safety.getByText("상향 확인", { exact: true })).toBeVisible();
|
||||
await safety.getByRole("button", { name: "근거 발화로 이동" }).click();
|
||||
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.locator(".sr-turn--active")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 축어록과 매칭되지 않는 근거 UUID를 축약된 코드로 확인한다
|
||||
test("축어록에 없는 근거 UUID는 축약 코드로 표시된다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
observations: [
|
||||
makeObservation(episodeId, {
|
||||
evidence_turn_ids: [UNKNOWN_TURN_UUID],
|
||||
}),
|
||||
],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
const evidence = card.locator(".rr-evidence-list");
|
||||
await expect(evidence.locator("code")).toHaveText("49999999…000099");
|
||||
await expect(
|
||||
evidence.getByRole("button", { name: /발화로 이동/ }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 교수자 정정이 반영된 장면에서 사람 판정이 최신임과 정정 이유를 읽는다
|
||||
test("교수자 정정이 append-only로 사람 판정 최신 표시와 이유를 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const first = makeObservation(episodeId, { sequence_no: 1 });
|
||||
const correction = makeObservation(episodeId, {
|
||||
sequence_no: 2,
|
||||
event_kind: "human.corrected",
|
||||
from_state: "partial",
|
||||
to_state: "resolved",
|
||||
source_kind: "human_rated",
|
||||
perspective: "supervisor_human",
|
||||
ai_view: "supervisor",
|
||||
confidence: null,
|
||||
uncertainty: 0.1,
|
||||
evidence_turn_ids: [TURN_UUIDS[3]],
|
||||
supersedes_observation_id: first.observation_id,
|
||||
correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.",
|
||||
created_at: "2026-08-06T09:18:00Z",
|
||||
});
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "resolved",
|
||||
status_source: "human_correction",
|
||||
observations: [first, correction],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
card.getByText("후속 반응 근거를 반영해 수선 확인으로 정정했습니다."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.locator(".rr-episode__badges").getByText("사람 판정", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.locator(".rr-episode__badges").getByText("수선 확인", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 정정 폼을 열어 기존 관찰을 고치지 않고 새 기록을 추가한다는 안내와 대상 관찰을 확인한다
|
||||
test("정정 폼이 보존형(append-only) 안내와 대상 관찰 ID를 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "teacher");
|
||||
const episodeId = nextEpisodeId();
|
||||
const observation = makeObservation(episodeId, {
|
||||
observation_id: "42000000-0000-4000-8000-000000000777",
|
||||
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
|
||||
});
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "partial",
|
||||
observations: [observation],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("teacher", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "teacher");
|
||||
await card.getByText("사람 판정으로 정정", { exact: true }).click();
|
||||
const correction = card.locator(".rr-correction");
|
||||
await expect(
|
||||
correction.getByRole("heading", { name: "최신 관찰을 보존형으로 정정" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
correction.getByText("기존 관찰은 수정하지 않고, 그 관찰을 대체하는 새 기록을 추가합니다."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
correction.locator(".rr-correction__intro code"),
|
||||
).toHaveText("42000000…000777");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 정정 이유를 쓰기 전에는 제출할 수 없고, 폼 기본값이 현재 판정·근거로 채워진 것을 본다
|
||||
test("정정 폼은 현재 판정·근거로 프리필되고 이유 없이는 제출이 막힌다", async ({ page }) => {
|
||||
await prepareReview(page, "teacher");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
current_status: "partial",
|
||||
observations: [
|
||||
makeObservation(episodeId, {
|
||||
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
|
||||
}),
|
||||
],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("teacher", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "teacher");
|
||||
await card.getByText("사람 판정으로 정정", { exact: true }).click();
|
||||
const form = card.locator(".rr-correction form");
|
||||
await expect(form.getByLabel("정정 상태")).toHaveValue("partial");
|
||||
await expect(
|
||||
form.locator(".rr-correction__evidence input[type=checkbox]:checked"),
|
||||
).toHaveCount(2);
|
||||
const submitButton = form.getByRole("button", { name: "정정 기록 추가" });
|
||||
await expect(submitButton).toBeDisabled();
|
||||
await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다.");
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 균열이 하나도 기록되지 않은 회기의 피드백 탭에서 차분한 빈 상태 안내를 본다
|
||||
test("에피소드 빈 배열이면 학습자용 빈 상태 안내를 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(
|
||||
card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByText(
|
||||
"회기에서 관계 균열과 수선 근거가 확인되면, 다시 연습할 장면을 여기서 준비할 수 있습니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 교수자가 원장이 아직 없는 회기(404)에서 역할에 맞는 빈 상태 안내를 본다
|
||||
test("교수자 404 빈 상태는 역할 허용 관찰 안내 문구를 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "teacher");
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, { detail: "rupture ledger not found" }, 404),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "teacher");
|
||||
await expect(
|
||||
card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByText(
|
||||
"역할에 허용된 균열·수선 관찰이 생기면 근거와 재조정 이력을 여기서 검토할 수 있습니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 원장 로딩 실패를 만나면 오류 카드에서 다시 불러오기를 눌러 복구한다
|
||||
test("원장 로딩 오류 후 다시 불러오기로 복구한다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
// StrictMode 이중 마운트에도 결정론적이도록, 테스트가 명시적으로 복구 시점을 제어한다.
|
||||
let healthy = false;
|
||||
let attempts = 0;
|
||||
await routeRuptures(page, async (route) => {
|
||||
attempts += 1;
|
||||
if (!healthy) {
|
||||
await fulfillJson(route, { detail: "temporary ledger failure" }, 500);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, readModel("learner", [makeEpisode()]));
|
||||
});
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(
|
||||
card.getByRole("heading", { name: "관계 장면 원장을 불러오지 못했습니다" }),
|
||||
).toBeVisible();
|
||||
healthy = true;
|
||||
await card.getByRole("button", { name: "다시 불러오기" }).click();
|
||||
await expect(
|
||||
card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" }),
|
||||
).toBeVisible();
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 근거·반대 근거·안전 사건이 비어 있는 장면에서 각 영역의 빈 안내 문구를 읽는다
|
||||
test("근거·반대 근거·안전 사건이 없으면 각 영역의 빈 안내를 보여준다", async ({ page }) => {
|
||||
await prepareReview(page, "learner");
|
||||
const episodeId = nextEpisodeId();
|
||||
const episode = makeEpisode({
|
||||
episode_id: episodeId,
|
||||
observations: [
|
||||
makeObservation(episodeId, {
|
||||
evidence_turn_ids: [],
|
||||
counterevidence: [],
|
||||
}),
|
||||
],
|
||||
safety_references: [],
|
||||
});
|
||||
await routeRuptures(page, (route) =>
|
||||
fulfillJson(route, readModel("learner", [episode])),
|
||||
);
|
||||
|
||||
const card = await openFeedback(page, "learner");
|
||||
await expect(card.getByText("연결된 발화 근거가 없습니다.")).toBeVisible();
|
||||
await expect(card.getByText("현재 원장에 기록된 반대 근거가 없습니다.")).toBeVisible();
|
||||
await expect(card.getByText("이 에피소드에 연결된 안전 사건이 없습니다.")).toBeVisible();
|
||||
await expect(
|
||||
card.locator(".rr-safety").getByText("연결 없음", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
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("");
|
||||
});
|
||||
});
|
||||
733
apps/web/e2e/uc-session-conversation.spec.ts
Normal file
733
apps/web/e2e/uc-session-conversation.spec.ts
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
/* =====================================================================
|
||||
uc-session-conversation.spec.ts — 회기 대화(텍스트 턴·SSE 스트림) 유스케이스 E2E
|
||||
|
||||
목적: 학습자가 텍스트 입력으로 내담자와 턴을 주고받는 실제 여정을
|
||||
화면 계약 기준으로 검증한다. (입력→전송→즉시 표시, SSE delta 누적,
|
||||
done 후 재활성화, 중복 전송 차단, 실패 시 작성문 보존, 자동 스크롤,
|
||||
PII placeholder 자연어 표시)
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx 의 composer/handleSend/스트림 소비 코드,
|
||||
apps/web/src/lib/api.ts openSessionStream(SSE: token/done/safety/error/ping,
|
||||
token data 는 원시 텍스트 조각), apps/web/src/lib/piiDisplay.ts 표시층 마스킹,
|
||||
apps/web/src/pages/session/voiceCapture.ts sessionVoiceStatusView
|
||||
(textComposerDisabled/textTurnBlocked 계약).
|
||||
|
||||
주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
|
||||
중복 회피: session-mvp.spec.ts(스트림 중 초안 작성 보존·코칭 흐름),
|
||||
session-layout.spec.ts(503 거절/수락 후 오류 시 로컬 축어록 제거),
|
||||
session-persistence.spec.ts(@single-run 실 DB 영속)과 각도를 다르게 잡았다.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const LEARNER_TEXT = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||
const REPLY_TOKENS = ["괜찮아요. ", "천천히 ", "말해볼게요."];
|
||||
const REPLY_FULL = "괜찮아요. 천천히 말해볼게요.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/* SSE 본문 빌더 — openSessionStream 의 파서 계약(event/data 라인, 빈 줄 dispatch)에 맞춘다.
|
||||
token 이벤트의 data 는 JSON 이 아니라 원시 텍스트 조각이다(api.ts dispatch 참고). */
|
||||
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");
|
||||
}
|
||||
|
||||
function doneEvent(turnSeq: number, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
session_id: SESSION_ID,
|
||||
stage: "라포",
|
||||
effective_openness: 0.34,
|
||||
turn_seq: turnSeq,
|
||||
safety_flagged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillTurn(
|
||||
route: Route,
|
||||
tokens: string[],
|
||||
done: Record<string, unknown>,
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: sseTurnBody(tokens, done),
|
||||
});
|
||||
}
|
||||
|
||||
interface DetailTurn {
|
||||
speaker: "learner" | "client";
|
||||
text: string;
|
||||
turn_seq: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/* 자동 스크롤 검증용 — 스크롤포트를 확실히 넘치게 하는 14턴 복원 대화 */
|
||||
const LONG_TURNS: DetailTurn[] = Array.from({ length: 14 }, (_, index) => {
|
||||
const learner = index % 2 === 0;
|
||||
return {
|
||||
speaker: learner ? "learner" : "client",
|
||||
text: learner
|
||||
? `그 순간에 어떤 생각이 스쳐 지나갔는지 조금 더 자세히 말해 줄 수 있을까요? 편한 만큼만 천천히 들려주세요. (${index + 1})`
|
||||
: `학교에 가려고 하면 가슴이 답답해지고, 아무 일도 없는데 눈물이 나요. 그런 제 모습이 스스로도 낯설어요. (${index + 1})`,
|
||||
turn_seq: index + 1,
|
||||
at: 20 + index * 10,
|
||||
};
|
||||
});
|
||||
|
||||
async function routeSessionScreen(
|
||||
page: Page,
|
||||
options: { detailTurns?: DetailTurn[] } = {},
|
||||
) {
|
||||
const startedAt = new Date(Date.now() - 300_000);
|
||||
|
||||
// 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-0000000uce01",
|
||||
email: "uc.learner@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/voice/speech", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "voice synthesis disabled in fixture" }, 503)),
|
||||
);
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-conv-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.21,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-conv-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: (options.detailTurns ?? []).map((turn) => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
turn_seq: turn.turn_seq,
|
||||
created_at: new Date(startedAt.getTime() + turn.at * 1000).toISOString(),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상은
|
||||
// 대화 왕복이고, 점검 잠금 자체는 session-mvp.spec.ts 가 검증한다.
|
||||
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: 3, max: 3 },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamController {
|
||||
readonly calls: number;
|
||||
readonly texts: string[];
|
||||
}
|
||||
|
||||
async function installStream(
|
||||
page: Page,
|
||||
respond: (index: number, route: Route) => Promise<void> | void,
|
||||
): Promise<StreamController> {
|
||||
const state = { calls: 0, texts: [] as string[] };
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, async (route) => {
|
||||
const index = state.calls;
|
||||
state.calls += 1;
|
||||
const body = route.request().postDataJSON() as { text?: string } | null;
|
||||
state.texts.push(body?.text ?? "");
|
||||
await respond(index, route);
|
||||
});
|
||||
return {
|
||||
get calls() {
|
||||
return state.calls;
|
||||
},
|
||||
get texts() {
|
||||
return state.texts;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startFreshSession(page: Page) {
|
||||
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.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
}
|
||||
|
||||
async function resumeSession(page: Page) {
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
}
|
||||
|
||||
function distanceFromBottom(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector(".sx-transcript__scroll");
|
||||
if (!el) return Number.MAX_SAFE_INTEGER;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
});
|
||||
}
|
||||
|
||||
const clientUtterance = (page: Page) =>
|
||||
page.locator(".sx-utt.is-client:not(.is-thinking)");
|
||||
const learnerUtterance = (page: Page) => page.locator(".sx-utt.is-learner");
|
||||
const composerOf = (page: Page) => page.getByLabel("학습자 발화 입력");
|
||||
const sendButtonOf = (page: Page) => page.getByRole("button", { name: "보내기" });
|
||||
|
||||
test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
|
||||
// usecase: 학습자가 아무것도 쓰지 않거나 공백만 쓴 채로는 발화를 보낼 수 없다
|
||||
test("빈 입력·공백 입력으로는 발화가 전송되지 않는다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, (_, route) =>
|
||||
fulfillTurn(route, REPLY_TOKENS, doneEvent(1)),
|
||||
);
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
const sendButton = sendButtonOf(page);
|
||||
|
||||
await expect(sendButton).toBeDisabled();
|
||||
await composer.fill(" ");
|
||||
await expect(sendButton).toBeDisabled();
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
||||
expect(stream.calls).toBe(0);
|
||||
|
||||
// 실제 문장을 쓰면 그제야 보내기가 살아난다.
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await expect(sendButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 발화를 보내면 응답을 기다리기 전에 내 발화가 즉시 자막에 남는다
|
||||
test("전송 즉시 학습자 발화가 자막에 표시되고 입력창이 비워진다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const gate = deferred();
|
||||
await installStream(page, async (_, route) => {
|
||||
await gate.promise;
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
// 서버 응답 전인데도 낙관적으로 학습자 발화가 먼저 보인다.
|
||||
const myUtterance = learnerUtterance(page).filter({ hasText: LEARNER_TEXT });
|
||||
await expect(myUtterance).toBeVisible();
|
||||
await expect(myUtterance).toContainText("나 (학습자)");
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(
|
||||
page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." }),
|
||||
).toBeVisible();
|
||||
|
||||
gate.resolve();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 내담자 응답이 여러 SSE 조각으로 흘러와도 하나의 말풍선에 이어 붙어 보인다
|
||||
test("여러 token delta 가 하나의 내담자 말풍선으로 누적 렌더된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await installStream(page, (_, route) =>
|
||||
fulfillTurn(
|
||||
route,
|
||||
["그 말을 들으니 ", "마음이 조금 ", "놓여요."],
|
||||
doneEvent(1, { stage: "탐색", effective_openness: 0.42 }),
|
||||
),
|
||||
);
|
||||
await startFreshSession(page);
|
||||
|
||||
await composerOf(page).fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
const clientBubble = clientUtterance(page);
|
||||
await expect(clientBubble).toHaveCount(1);
|
||||
await expect(clientBubble.locator(".sx-utt__line")).toHaveText(
|
||||
"그 말을 들으니 마음이 조금 놓여요.",
|
||||
);
|
||||
// done 이후에는 진행 중(partial) 표시가 남지 않는다.
|
||||
await expect(page.locator(".sx-utt.is-partial")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 한 턴이 끝나면 학습자는 바로 다음 발화를 이어서 보낼 수 있다
|
||||
test("done 후 보내기가 재활성화되어 두 번째 턴을 이어갈 수 있다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, async (index, route) => {
|
||||
if (index === 0) {
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
return;
|
||||
}
|
||||
await fulfillTurn(
|
||||
route,
|
||||
["오늘은 조금 나은 것 같아요."],
|
||||
doneEvent(2, { stage: "탐색", effective_openness: 0.45 }),
|
||||
);
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
const sendButton = sendButtonOf(page);
|
||||
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButton.click();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
|
||||
const secondText = "그 마음이 언제부터 시작됐는지 기억나는 장면이 있을까요?";
|
||||
await composer.fill(secondText);
|
||||
await expect(sendButton).toBeEnabled();
|
||||
await sendButton.click();
|
||||
|
||||
await expect(
|
||||
clientUtterance(page).filter({ hasText: "오늘은 조금 나은 것 같아요." }),
|
||||
).toBeVisible();
|
||||
await expect(learnerUtterance(page)).toHaveCount(2);
|
||||
expect(stream.calls).toBe(2);
|
||||
expect(stream.texts).toEqual([LEARNER_TEXT, secondText]);
|
||||
});
|
||||
|
||||
// usecase: 응답이 생성되는 동안 학습자가 조급하게 연타해도 턴이 중복 전송되지 않는다
|
||||
test("스트림 중에는 보내기가 비활성화되고 Enter 연타도 중복 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page);
|
||||
const gate = deferred();
|
||||
const stream = await installStream(page, async (_, route) => {
|
||||
await gate.promise;
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
const sendButton = sendButtonOf(page);
|
||||
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButton.click();
|
||||
await expect(sendButton).toBeDisabled();
|
||||
|
||||
// 대기 중에도 다음 발화 작성은 허용되지만, Enter 연타는 직렬 턴 계약에 막힌다.
|
||||
const draft = "혹시 지금도 그 생각이 이어지고 있나요?";
|
||||
await composer.fill(draft);
|
||||
await composer.press("Enter");
|
||||
await composer.press("Enter");
|
||||
expect(stream.calls).toBe(1);
|
||||
|
||||
gate.resolve();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
// 연타한 초안은 전송되지 않고 작성 중 상태로 남아 있어야 한다.
|
||||
await expect(composer).toHaveValue(draft);
|
||||
expect(stream.calls).toBe(1);
|
||||
await expect(learnerUtterance(page)).toHaveCount(1);
|
||||
await expect(learnerUtterance(page).filter({ hasText: draft })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 아주 긴 발화(2000자)를 정리해 보내도 잘리지 않고 그대로 전달된다
|
||||
test("2000자 긴 발화도 잘리지 않고 전송·표시된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, (_, route) =>
|
||||
fulfillTurn(route, ["긴 이야기를 끝까지 들려줘서 고마워요."], doneEvent(1)),
|
||||
);
|
||||
await startFreshSession(page);
|
||||
|
||||
const longText = "오늘있었던일을차분히말해봅니다".repeat(134).slice(0, 2000);
|
||||
expect(longText).toHaveLength(2000);
|
||||
|
||||
await composerOf(page).fill(longText);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
await expect(
|
||||
learnerUtterance(page).filter({ hasText: longText.slice(0, 30) }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
clientUtterance(page).filter({ hasText: "긴 이야기를 끝까지 들려줘서 고마워요." }),
|
||||
).toBeVisible();
|
||||
expect(stream.texts[0]).toHaveLength(2000);
|
||||
expect(stream.texts[0]).toBe(longText);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
// usecase: 전송이 서버 500 으로 거절되면 학습자의 작성문이 사라지지 않고 오류를 안내받는다
|
||||
test("전송이 500으로 실패하면 작성문이 보존되고 오류가 표시된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, (_, route) =>
|
||||
route.fulfill(jsonRoute({ detail: "uc fixture internal failure" }, 500)),
|
||||
);
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
const turnAlert = page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "내담자 응답을 생성하지 못했습니다" });
|
||||
await expect(turnAlert).toBeVisible();
|
||||
await expect(turnAlert).toContainText("엔진 연결");
|
||||
// 실패한 턴은 축어록에 남지 않고, 작성문은 재시도할 수 있게 복원된다.
|
||||
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
||||
await expect(composer).toHaveValue(LEARNER_TEXT);
|
||||
await expect(sendButtonOf(page)).toBeEnabled();
|
||||
expect(stream.calls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 스트림 도중 오류가 나면 실패로 안내받고, 부분 응답이 성공처럼 남지 않으며 재시도로 회복한다
|
||||
test("스트림 도중 오류 이벤트가 오면 실패로 표시하고 재시도로 회복한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page);
|
||||
const partialText = "그게 무슨 말인지 잘";
|
||||
const stream = await installStream(page, async (index, route) => {
|
||||
if (index === 0) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: [
|
||||
"event: token",
|
||||
`data: ${partialText}`,
|
||||
"",
|
||||
"event: error",
|
||||
'data: {"detail":"uc fixture stream cut"}',
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
// 실패 상태를 정직하게 알린다 — 부분 응답을 성공으로 위장하지 않는다.
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "내담자 응답을 생성하지 못했습니다" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
||||
await expect(page.getByText(partialText)).toHaveCount(0);
|
||||
await expect(composer).toHaveValue(LEARNER_TEXT);
|
||||
|
||||
// 같은 작성문으로 재시도하면 정상 왕복으로 회복한다.
|
||||
await sendButtonOf(page).click();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "내담자 응답을 생성하지 못했습니다" }),
|
||||
).toHaveCount(0);
|
||||
expect(stream.calls).toBe(2);
|
||||
});
|
||||
|
||||
// usecase: 내담자가 응답을 내지 못한 턴은 빈 말풍선 없이 정직하게 끝나고 다음 발화를 이어갈 수 있다
|
||||
test("빈 내담자 응답 턴은 가짜 말풍선 없이 종료되고 대화를 이어갈 수 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, async (index, route) => {
|
||||
if (index === 0) {
|
||||
await fulfillTurn(route, [], doneEvent(1));
|
||||
return;
|
||||
}
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(2));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
// 학습자 발화는 저장돼 남고, 내담자 말풍선·대기 표시는 만들어지지 않는다.
|
||||
await expect(learnerUtterance(page).filter({ hasText: LEARNER_TEXT })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt.is-thinking")).toHaveCount(0);
|
||||
await expect(clientUtterance(page)).toHaveCount(0);
|
||||
|
||||
// 대화는 막히지 않는다 — 다음 발화로 정상 왕복.
|
||||
await composer.fill("천천히 정리해서 다시 이야기해 볼까요?");
|
||||
await expect(sendButtonOf(page)).toBeEnabled();
|
||||
await sendButtonOf(page).click();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
expect(stream.calls).toBe(2);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 Enter 로 발화를 보내고, Shift+Enter 로는 줄바꿈만 한다
|
||||
test("Enter 는 전송, Shift+Enter 는 줄바꿈으로 동작한다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
const stream = await installStream(page, (_, route) =>
|
||||
fulfillTurn(route, REPLY_TOKENS, doneEvent(1)),
|
||||
);
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill("첫 마음은 여기까지 이야기했어요.");
|
||||
await composer.press("Shift+Enter");
|
||||
await composer.pressSequentially("이어서 두 번째 마음도 있어요.");
|
||||
|
||||
// Shift+Enter 는 전송하지 않고 줄바꿈만 추가한다.
|
||||
expect(stream.calls).toBe(0);
|
||||
await expect(composer).toHaveValue(
|
||||
"첫 마음은 여기까지 이야기했어요.\n이어서 두 번째 마음도 있어요.",
|
||||
);
|
||||
|
||||
await composer.press("Enter");
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
expect(stream.calls).toBe(1);
|
||||
expect(stream.texts[0]).toBe(
|
||||
"첫 마음은 여기까지 이야기했어요.\n이어서 두 번째 마음도 있어요.",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 대화가 길어져도 새 내담자 응답이 오면 자막이 최신 발화 위치로 따라간다
|
||||
test("새 응답이 도착하면 자막이 자동으로 최신 위치까지 스크롤된다", async ({ page }) => {
|
||||
await routeSessionScreen(page, { detailTurns: LONG_TURNS });
|
||||
await installStream(page, (_, route) =>
|
||||
fulfillTurn(
|
||||
route,
|
||||
["말씀을 듣고 보니 오늘은 저도 제 마음을 조금 더 열어 보고 싶어졌어요."],
|
||||
doneEvent(LONG_TURNS.length + 2),
|
||||
),
|
||||
);
|
||||
await resumeSession(page);
|
||||
|
||||
// 복원 직후에도 최신 발화를 따라간다. (스크롤포트가 실제로 넘치는지 먼저 보증)
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.querySelector(".sx-transcript__scroll");
|
||||
if (!el) return 0;
|
||||
return el.scrollHeight - el.clientHeight;
|
||||
});
|
||||
expect(overflow).toBeGreaterThan(50);
|
||||
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
||||
|
||||
await composerOf(page).fill("지금 이야기하면서 어떤 느낌이 드는지 궁금해요.");
|
||||
await sendButtonOf(page).click();
|
||||
await expect(
|
||||
clientUtterance(page).filter({ hasText: "마음을 조금 더 열어 보고 싶어졌어요." }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
||||
});
|
||||
|
||||
// usecase: 위로 올려 이전 대화를 읽는 동안에는 새 응답이 와도 시선을 빼앗지 않고, 최신으로 버튼으로 복귀한다
|
||||
test("위로 스크롤해 읽는 중에는 자동 스크롤이 멈추고 최신으로 버튼으로 복귀한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page, { detailTurns: LONG_TURNS });
|
||||
await installStream(page, (_, route) =>
|
||||
fulfillTurn(
|
||||
route,
|
||||
["그때 이야기를 다시 꺼내 줘서 고마워요. 조금 더 말해 볼게요."],
|
||||
doneEvent(LONG_TURNS.length + 2),
|
||||
),
|
||||
);
|
||||
await resumeSession(page);
|
||||
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
||||
|
||||
// 사용자가 직접(wheel 의도) 맨 위로 이동해 과거 발화를 읽는다.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector<HTMLElement>(".sx-transcript__scroll");
|
||||
if (!el) return;
|
||||
el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -400 }));
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
const jumpButton = page.getByRole("button", { name: "최신으로" });
|
||||
await expect(jumpButton).toBeVisible();
|
||||
|
||||
// 읽는 중 새 턴이 진행돼도 화면 위치를 빼앗지 않는다.
|
||||
await composerOf(page).fill("방금 하신 말이 마음에 남아서요.");
|
||||
await sendButtonOf(page).click();
|
||||
await expect(
|
||||
clientUtterance(page).filter({ hasText: "조금 더 말해 볼게요." }),
|
||||
).toBeVisible();
|
||||
const scrollTop = await page.evaluate(
|
||||
() => document.querySelector(".sx-transcript__scroll")?.scrollTop ?? -1,
|
||||
);
|
||||
expect(scrollTop).toBeLessThan(50);
|
||||
await expect.poll(() => distanceFromBottom(page)).toBeGreaterThan(200);
|
||||
|
||||
// 최신으로 버튼을 누르면 다시 하단 추적 모드로 돌아간다.
|
||||
await jumpButton.click();
|
||||
await expect.poll(() => distanceFromBottom(page)).toBeLessThan(24);
|
||||
await expect(jumpButton).toBeHidden();
|
||||
});
|
||||
|
||||
// usecase: 저장 마스킹된 [NAME] 토큰이 학습자에게는 자연스러운 한국어 문장으로 보인다
|
||||
test("저장된 회기의 [NAME] 마스킹이 자연어 문장으로 표시된다", async ({ page }) => {
|
||||
await routeSessionScreen(page, {
|
||||
detailTurns: [
|
||||
{
|
||||
speaker: "learner",
|
||||
text: "어머니 이야기를 조금 더 들려줄 수 있을까요?",
|
||||
turn_seq: 1,
|
||||
at: 30,
|
||||
},
|
||||
{
|
||||
speaker: "client",
|
||||
text: "[NAME]은 제 얘기를 끝까지 듣지 않아요. [NAME]이 자꾸 저를 몰아세워요.",
|
||||
turn_seq: 2,
|
||||
at: 45,
|
||||
},
|
||||
],
|
||||
});
|
||||
await resumeSession(page);
|
||||
|
||||
const transcript = page.locator(".sx-transcript__scroll");
|
||||
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 내담자는, [NAME]이→익명 내담자가.
|
||||
await expect(transcript).toContainText("익명 내담자는 제 얘기를 끝까지 듣지 않아요.");
|
||||
await expect(transcript).toContainText("익명 내담자가 자꾸 저를 몰아세워요.");
|
||||
await expect(transcript).not.toContainText("[NAME]");
|
||||
});
|
||||
|
||||
// usecase: 새 응답을 기다리는 동안에도 학습자는 다음 발화를 미리 작성할 수 있다 (입력창은 살아 있다)
|
||||
test("응답 대기 중에도 입력창은 열려 있어 다음 발화를 미리 작성할 수 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page);
|
||||
const gate = deferred();
|
||||
await installStream(page, async (_, route) => {
|
||||
await gate.promise;
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
// 직렬 턴 계약: 보내기는 잠기지만 작성 자체는 막지 않는다(voiceCapture.ts 계약).
|
||||
await expect(sendButtonOf(page)).toBeDisabled();
|
||||
await expect(composer).toBeEnabled();
|
||||
const draft = "다음에는 그날의 장면을 함께 그려 보고 싶어요.";
|
||||
await composer.fill(draft);
|
||||
|
||||
gate.resolve();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
// 미리 쓴 초안은 응답 도착 후에도 지워지지 않고, 이제 보낼 수 있다.
|
||||
await expect(composer).toHaveValue(draft);
|
||||
await expect(sendButtonOf(page)).toBeEnabled();
|
||||
});
|
||||
});
|
||||
575
apps/web/e2e/uc-session-prestart.spec.ts
Normal file
575
apps/web/e2e/uc-session-prestart.spec.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
/* =====================================================================
|
||||
uc-session-prestart.spec.ts — 유스케이스: 회기 사전 설정(목표 선택) 여정
|
||||
|
||||
목적: 학습자가 /learn/session/:personaCode 프리스타트 화면에서
|
||||
· 내담자(페르소나) 정보·난이도를 확인하고
|
||||
· 이번 회기 목표(1~4개)를 선택/해제하고
|
||||
· 이론모드를 고른 뒤
|
||||
· "회기 시작"으로 active 회기에 진입하는
|
||||
실제 사용자 행동을 검증한다.
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx 프리스타트 렌더(sx-prestart 블록,
|
||||
toggleGoal 1~4개 자유 선택, canStartSession/selectedGoals.length 게이트),
|
||||
src/lib/practiceLaunchIntent.ts (G4 처방 연습 deliberate / G5 전이 검증
|
||||
transfer launch intent 파싱·URL 보존), src/lib/personaViewModel.ts
|
||||
(DIFFICULTY_LABEL, isUsablePersona).
|
||||
|
||||
중복 회피: full-sweep-session.spec.ts(동의 게이트·시간 알람·active 상호작용),
|
||||
self-directed-learning-loop.spec.ts(deliberate replay 처방 흐름·목표 추가
|
||||
선택 1건)와 겹치지 않도록 목표 0개 차단·전체 선택·해제 토글·키보드 조작·
|
||||
transfer(G5) intent·잘못된 intent 차단 각도로 검증한다.
|
||||
|
||||
전부 route fixture 기반이며 실제 AI 엔진 턴 생성 없음.
|
||||
모바일(≤720px)은 진행 초점 패널·칩·힌트 문구를 의도적으로 숨기므로
|
||||
(session.css @media max-width:720px) 해당 어설션은 desktop 전용으로 가드한다.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const FIXTURE_SESSION_ID = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
interface PrestartFixture {
|
||||
startRequests: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** 프리스타트 여정용 route fixture — catch-all 먼저, 개별 fixture 나중(나중 등록 우선). */
|
||||
async function routePrestartFixture(page: Page): Promise<PrestartFixture> {
|
||||
const startRequests: Array<Record<string, unknown>> = [];
|
||||
|
||||
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-00000ucps301",
|
||||
email: "prestart.learner@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/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,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
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/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: true, reason: null })),
|
||||
);
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const body = (route.request().postDataJSON() ?? {}) as Record<string, unknown>;
|
||||
startRequests.push(body);
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
session_id: FIXTURE_SESSION_ID,
|
||||
case_id: "uc-prestart-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.21,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
goal_stages: body.goal_stages ?? ["라포", "탐색"],
|
||||
duration_limit_seconds: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// 시작 직후 URL 전환은 justStartedSessionRef로 재로딩을 건너뛰지만,
|
||||
// 안전망으로 재개 스냅샷도 결정론적으로 응답한다.
|
||||
await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: FIXTURE_SESSION_ID,
|
||||
case_id: "uc-prestart-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: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}/alliance-pulses`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab",
|
||||
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/${FIXTURE_SESSION_ID}/live-coach`, (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
source: "database",
|
||||
quota: { remaining: 3, max: 3 },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
route.fulfill(jsonRoute({ detail: "not used in prestart usecases" }, 404));
|
||||
});
|
||||
|
||||
return { startRequests };
|
||||
}
|
||||
|
||||
function goalsGroup(page: Page) {
|
||||
return page.getByRole("group", { name: "이번 회기 목표 선택" });
|
||||
}
|
||||
|
||||
function startButton(page: Page) {
|
||||
return page.getByRole("button", { name: "회기 시작" });
|
||||
}
|
||||
|
||||
async function gotoPrestart(page: Page, search = "") {
|
||||
await page.goto(`/learn/session/P1${search}`);
|
||||
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||
}
|
||||
|
||||
// G5 전이 검증(transfer) launch intent 쿼리 — practiceLaunchSearch와 동일한 파라미터 구성.
|
||||
const TRANSFER_LAUNCH_SEARCH =
|
||||
"?launch=transfer" +
|
||||
"&prescription=rx-transfer-001" +
|
||||
"&source_session=88888888-8888-4888-8888-888888888888" +
|
||||
"&criterion=competency.empathic_reflection" +
|
||||
"&novelty=unseen_transfer" +
|
||||
"&mode=counterevidence_forecast" +
|
||||
"&suite=suite-empathy-01" +
|
||||
"&trial=trial-empathy-01a";
|
||||
|
||||
// G4 처방 연습(deliberate) launch intent 쿼리 — self-directed 스펙의 replay와 겹치지 않게
|
||||
// difficulty_ladder 모드를 쓴다.
|
||||
const DELIBERATE_LAUNCH_SEARCH =
|
||||
"?launch=deliberate" +
|
||||
"&prescription=rx-ladder-001" +
|
||||
"&source_session=99999999-9999-4999-8999-999999999999" +
|
||||
"&criterion=competency.open_question" +
|
||||
"&novelty=familiar" +
|
||||
"&mode=difficulty_ladder";
|
||||
|
||||
test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||
// usecase: 학습자가 프리스타트 화면에서 내담자 이름·호소 문제·난이도를 확인한다
|
||||
test("시작 전 화면에서 내담자 정보와 고난도 표시를 확인한다", async ({ page }, testInfo) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "민서님과의 회기를 시작할까요?" }),
|
||||
).toBeVisible();
|
||||
|
||||
const facts = page.locator(".sx-prestart__facts");
|
||||
await expect(facts).toContainText("호소");
|
||||
await expect(facts).toContainText("자퇴와 무기력감을 둘러싼 상담 연습");
|
||||
await expect(facts).toContainText("난도");
|
||||
await expect(facts).toContainText("고난도");
|
||||
await expect(facts).toContainText("인간중심");
|
||||
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
// 데스크톱: 아바타 카드의 내담자 요약 + 태그 칩(P1·고난도)까지 확인한다.
|
||||
const caseCard = page.locator(".sx-prestart__case");
|
||||
await expect(caseCard).toContainText("민서");
|
||||
await expect(caseCard).toContainText("교육용 가상 내담자");
|
||||
const chips = page.locator(".sx-prestart__chips");
|
||||
await expect(chips).toContainText("P1");
|
||||
await expect(chips.locator("span.is-clay")).toHaveText("고난도");
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다
|
||||
test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
|
||||
await expect(settingsSummary).toContainText("인간중심, 라포 · 탐색");
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
const plan = page.locator(".sx-prestart__plan");
|
||||
await expect(plan).toContainText("2개 선택");
|
||||
await expect(plan).toContainText("라포 · 탐색");
|
||||
await expect(plan).toContainText("60분 회기");
|
||||
await expect(plan).toContainText("종료 10분 전 알림");
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다
|
||||
test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
await goals.getByRole("button", { name: /라포/ }).click();
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expect(startButton(page)).toBeDisabled();
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("목표 선택 필요");
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(
|
||||
page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요");
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
|
||||
test("목표를 다시 1개 선택하면 시작 차단이 풀린다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await goals.getByRole("button", { name: /라포/ }).click();
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
await expect(startButton(page)).toBeDisabled();
|
||||
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("인간중심, 개입");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
|
||||
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
const goalButtons = goals.getByRole("button");
|
||||
// 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약.
|
||||
await expect(goalButtons).toHaveCount(4);
|
||||
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
await goals.getByRole("button", { name: /정리/ }).click();
|
||||
for (const name of [/라포/, /탐색/, /개입/, /정리/]) {
|
||||
await expect(goals.getByRole("button", { name })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("라포 · 탐색 · 개입 · 정리");
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText("4개 선택");
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
|
||||
test("선택된 목표를 다시 누르면 해제되고 요약이 갱신된다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
const exploreGoal = goals.getByRole("button", { name: /탐색/ });
|
||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await exploreGoal.click();
|
||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("인간중심, 라포");
|
||||
// 나머지 기본 목표는 그대로 유지된다.
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
|
||||
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const theory = page.getByRole("group", { name: "이론모드 선택" });
|
||||
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
|
||||
await theory.getByRole("button", { name: /CBT/ }).click();
|
||||
await expect(theory.getByRole("button", { name: /CBT/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("CBT, 라포 · 탐색");
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
||||
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
|
||||
test("회기 시작을 누르면 active 화면으로 전환되고 선택한 목표가 요청에 실린다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
// 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증).
|
||||
await goalsGroup(page).getByRole("button", { name: /탐색/ }).click();
|
||||
await startButton(page).click();
|
||||
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${FIXTURE_SESSION_ID}$`));
|
||||
expect(fixture.startRequests).toHaveLength(1);
|
||||
expect(fixture.startRequests[0]).toMatchObject({
|
||||
persona_code: "P1",
|
||||
theory_mode: "humanistic",
|
||||
goal_stages: ["라포"],
|
||||
});
|
||||
// active 전환 뒤 목표 단계에 '목표' 배지가 붙는다(시작 응답의 goal_stages 반영).
|
||||
await expect(page.locator(".sx-ph.is-goal")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 마우스 없이 키보드만으로 목표를 선택/해제한다
|
||||
test("키보드 Space·Enter로 목표를 토글할 수 있다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
const interveneGoal = goals.getByRole("button", { name: /개입/ });
|
||||
await expect(interveneGoal).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await interveneGoal.focus();
|
||||
await expect(interveneGoal).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(interveneGoal).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(interveneGoal).toHaveAttribute("aria-pressed", "false");
|
||||
// 키보드 해제 뒤에도 남은 목표로 시작은 계속 가능하다.
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다
|
||||
test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page, DELIBERATE_LAUNCH_SEARCH);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "처방 연습 · 난도 사다리" }),
|
||||
).toBeVisible();
|
||||
const context = page.locator(".sx-practice-launch-context");
|
||||
await expect(context).toContainText("익숙한 장면");
|
||||
await expect(context).toContainText("성공 기준 개방형 질문");
|
||||
await expect(context).toContainText("원본 회기 참조");
|
||||
await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다");
|
||||
// 내부 식별자는 학습자 본문에 노출하지 않는다.
|
||||
await expect(page.locator(".sx-page--prestart")).not.toContainText(
|
||||
"competency.open_question",
|
||||
);
|
||||
// 처방 진입에서는 회기 설정 상세가 접힌 채 시작 행동에 집중한다.
|
||||
const settings = page.locator("details.sx-prestart__settings.is-prescribed");
|
||||
await expect(settings).not.toHaveAttribute("open", "");
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 전이 검증(G5 transfer)에서 넘어온 학습자가 전이 과제 맥락을 확인한다
|
||||
test("G5 전이 검증 launch intent가 처음 보는 장면 맥락으로 표시된다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }),
|
||||
).toBeVisible();
|
||||
const context = page.locator(".sx-practice-launch-context");
|
||||
await expect(context).toContainText("처음 보는 장면");
|
||||
await expect(context).toContainText("성공 기준 공감적 반영");
|
||||
await expect(context).toContainText("전이 과제 출처 확인됨");
|
||||
await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다");
|
||||
await expect(page.locator(".sx-page--prestart")).not.toContainText(
|
||||
"competency.empathic_reflection",
|
||||
);
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 전이 검증 처방으로 회기를 시작해도 출처 쿼리가 URL에 그대로 남는다
|
||||
test("launch intent 쿼리가 회기 시작 후에도 URL에 유지된다", async ({ page }) => {
|
||||
const fixture = await routePrestartFixture(page);
|
||||
await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH);
|
||||
|
||||
await startButton(page).click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/learn/session/${FIXTURE_SESSION_ID}\\?`),
|
||||
);
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get("launch")).toBe("transfer");
|
||||
expect(url.searchParams.get("mode")).toBe("counterevidence_forecast");
|
||||
expect(url.searchParams.get("novelty")).toBe("unseen_transfer");
|
||||
expect(url.searchParams.get("suite")).toBe("suite-empathy-01");
|
||||
expect(url.searchParams.get("trial")).toBe("trial-empathy-01a");
|
||||
expect(url.searchParams.get("source_session")).toBe(
|
||||
"88888888-8888-4888-8888-888888888888",
|
||||
);
|
||||
expect(fixture.startRequests).toHaveLength(1);
|
||||
// active 화면도 처방 모드를 유지한다(data-practice-mode 계약).
|
||||
await expect(page.locator(".sx-page--active")).toHaveAttribute(
|
||||
"data-practice-mode",
|
||||
"counterevidence_forecast",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 조작된/깨진 처방 링크로 진입한 학습자는 일반 회기로 축약되지 않고 차단 안내를 받는다
|
||||
test("검증 불가한 launch 쿼리는 오류 안내와 함께 회기 시작을 차단한다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
// prescription 파라미터가 빠진 deliberate intent — parsePracticeLaunchIntent가 null을 반환.
|
||||
await gotoPrestart(
|
||||
page,
|
||||
"?launch=deliberate&source_session=99999999-9999-4999-8999-999999999999" +
|
||||
"&criterion=competency.open_question&novelty=familiar&mode=replay",
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "연습 처방의 출처를 다시 확인해 주세요." }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("alert").filter({
|
||||
hasText: "연습 처방의 출처를 검증할 수 없습니다. 일반 회기로 축약하지 않았습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(startButton(page)).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 프리스타트에서 마음을 바꿔 브라우저 뒤로가기로 학습자 홈에 돌아간다
|
||||
test("뒤로가기로 프리스타트를 떠나 학습자 홈으로 복귀한다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await page.goto("/learn");
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
|
||||
await gotoPrestart(page);
|
||||
await expect(startButton(page)).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
await expect(page.locator(".sx-page--prestart")).toHaveCount(0);
|
||||
await expect(startButton(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
801
apps/web/e2e/uc-session-review-core.spec.ts
Normal file
801
apps/web/e2e/uc-session-review-core.spec.ts
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
/**
|
||||
* 유스케이스 스펙 — 회기 리뷰 핵심 여정 (uc-session-review-core)
|
||||
*
|
||||
* 목적: 학습자·교수자가 회기 리뷰 화면에서 실제로 수행하는 핵심 행동
|
||||
* (축어록 턴 평가 확인, 근거 패널 열기/닫기, 근거 인용·앵커 이동,
|
||||
* 워크시트 작성·저장, 치료 동맹 자기평가 잠금, 평가 재시도)을 검증한다.
|
||||
*
|
||||
* 근거: apps/web/src/pages/SessionReview.tsx, session-review/model.ts,
|
||||
* session-review/AlliancePulseCard.tsx, e2e/session-review-fixture.ts.
|
||||
* 모든 API는 route fixture로 목킹하며 실제 AI 엔진 턴 생성은 없다.
|
||||
*
|
||||
* 중복 회피: session-review.spec.ts(폴링·리다이렉트·플레이스홀더 마스킹·prepost 저장),
|
||||
* full-sweep-session-review.spec.ts(가드·공유·필터·client 턴 점프·루브릭·교수자 검수)와
|
||||
* 겹치지 않는 각도만 다룬다.
|
||||
*/
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
filledReviewResponse,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
/** 이 스펙이 목킹하지 않은 API는 전부 404 — 실 API 없이 결정론적으로 돈다. */
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedLearner(page: Page) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-000000000101",
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedTeacher(page: Page) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-000000000202",
|
||||
email: "teacher@hs.ac.kr",
|
||||
role: "teacher",
|
||||
display_name: "교수자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "교수자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 리뷰는 전 폭에서 상단 가로 탭으로 영역을 전환한다(SessionReview.tsx REVIEW_TABS). */
|
||||
async function openReviewTab(
|
||||
page: Page,
|
||||
label: "축어록" | "피드백" | "워크시트",
|
||||
) {
|
||||
const tabs = page.locator(".sr-tabs");
|
||||
await tabs.waitFor({ state: "attached", timeout: 10_000 });
|
||||
await tabs.getByRole("tab", { name: label }).click();
|
||||
}
|
||||
|
||||
/** 공개(revealed)된 치료 동맹 펄스 — 관점 비교와 근거 패널이 렌더되는 상태. */
|
||||
function revealedPulse() {
|
||||
return {
|
||||
pulse_id: "pulse-ready-1",
|
||||
checkpoint: "post",
|
||||
status: "ready",
|
||||
learner_locked_at: "2026-06-27T10:00:00+00:00",
|
||||
revealed_at: "2026-06-27T10:05:00+00:00",
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.75, task: 0.5, bond: 0.75 },
|
||||
measurements: [
|
||||
{
|
||||
measurement_id: "m-goal-client",
|
||||
dimension: "goal",
|
||||
perspective: "client_agent_report",
|
||||
source_kind: "client_agent",
|
||||
value: 0.5,
|
||||
confidence: 0.8,
|
||||
status: "ready",
|
||||
error_code: null,
|
||||
rationale: "내담자는 이번 회기에서 다루려는 주제를 대체로 이해했다고 보고했습니다.",
|
||||
evidence: [
|
||||
{
|
||||
turn_id: "t3",
|
||||
seq: 3,
|
||||
speaker: "client",
|
||||
text: "그때는 그냥 화가 난다고 말하면 제가 더 나쁜 사람이 될 것 같아서 아무 말도 못 했어요.",
|
||||
},
|
||||
],
|
||||
created_at: "2026-06-27T10:05:00+00:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("uc session review core", () => {
|
||||
// usecase: 학습자가 축어록에서 자기 발화에 붙은 AI 턴 평가 상세와 발화 근거 인용을 확인한다
|
||||
test("축어록 턴에 붙은 AI 평가 상세와 발화 근거 인용을 확인한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-turn-note-detail";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
// 기본 탭은 축어록 — t2 학습자 발화의 긍정 평가 노트가 열려 있다.
|
||||
const goodNote = page.locator(".sr-note", { hasText: "좋은 연결" });
|
||||
await expect(goodNote).toBeVisible();
|
||||
await expect(goodNote).toContainText(
|
||||
"내담자의 경험을 평가하지 않고 정서와 의미를 함께 되짚었습니다.",
|
||||
);
|
||||
await expect(goodNote.locator(".sr-note__quote")).toContainText("발화 근거");
|
||||
await expect(goodNote.locator(".sr-note__quote")).toContainText(
|
||||
"혼자서 버티고 있었다는 느낌",
|
||||
);
|
||||
|
||||
// t5 학습자 발화의 주의(warn) 톤 노트도 근거 인용과 함께 표시된다.
|
||||
const warnNote = page.locator(".sr-note", { hasText: "속도 점검" });
|
||||
await expect(warnNote).toHaveClass(/sr-note--warn/);
|
||||
await expect(warnNote.locator(".sr-note__quote")).toContainText(
|
||||
"연습부터 해볼 수 있을까요",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 관점 비교에서 AI 측정의 근거 상세 패널을 열었다가 닫는다
|
||||
test("치료 동맹 관점 비교의 근거 상세 패널을 열고 닫는다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-evidence-panel-toggle";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [revealedPulse()] })),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
const comparison = page.locator(".ap-comparison");
|
||||
await expect(comparison).toBeVisible();
|
||||
const evidencePanel = comparison.locator("details.ap-measurement-evidence");
|
||||
await expect(evidencePanel).toHaveCount(1);
|
||||
const rationale = evidencePanel.locator("p", {
|
||||
hasText: "다루려는 주제를 대체로 이해했다고",
|
||||
});
|
||||
await expect(rationale).toBeHidden();
|
||||
|
||||
// 열기 — 판단 근거 서술과 근거 발화 버튼이 함께 나타난다.
|
||||
await evidencePanel.locator("summary").click();
|
||||
await expect(rationale).toBeVisible();
|
||||
await expect(
|
||||
evidencePanel.getByRole("button", { name: /3번째 발화/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// 닫기 — 상세가 다시 숨는다.
|
||||
await evidencePanel.locator("summary").click();
|
||||
await expect(rationale).toBeHidden();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 관점 비교의 근거 발화 버튼으로 축어록의 해당 턴으로 이동한다
|
||||
test("관점 비교의 근거 발화 버튼으로 축어록 턴으로 이동한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-evidence-jump";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [revealedPulse()] })),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
const evidencePanel = page.locator("details.ap-measurement-evidence");
|
||||
await evidencePanel.locator("summary").click();
|
||||
await evidencePanel.getByRole("button", { name: /3번째 발화/ }).click();
|
||||
|
||||
// jumpToTurn은 축어록 탭으로 전환하며 대상 턴을 활성화한다.
|
||||
const activeTurn = page.locator(".sr-turn--active");
|
||||
await expect(activeTurn).toHaveCount(1);
|
||||
await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 리뷰 화면에서 영문 이론키(raw 토큰) 대신 자연어 라벨만 본다
|
||||
test("난도·작성자·신뢰도 라벨이 영문 원문 키 없이 자연어로 표시된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-natural-labels";
|
||||
const response = filledReviewResponse(sessionId);
|
||||
// API가 원문 난도 토큰과 원문 작성자 키를 그대로 보낸 상황을 가정한다.
|
||||
response.client.persona = "P1 · hard · 인본주의";
|
||||
if (!response.turns[1].note) throw new Error("fixture must include t2 note");
|
||||
response.turns[1].note = { ...response.turns[1].note, author: "ai" };
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
|
||||
route.fulfill(jsonRoute(response)),
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
// 헤더 페르소나 칩: hard → 고난도 치환(personaMetaParts).
|
||||
const headTags = page.locator(".sr-head__tags");
|
||||
await expect(headTags.getByText("고난도", { exact: true })).toBeVisible();
|
||||
await expect(headTags).not.toContainText("hard");
|
||||
|
||||
// 턴 노트 작성자 키: "ai" → "AI"(noteAuthorLabel).
|
||||
const goodNote = page.locator(".sr-note", { hasText: "좋은 연결" });
|
||||
await expect(goodNote.locator(".sr-note__tag")).toHaveText("AI");
|
||||
|
||||
// 워크시트 신뢰도 키: medium → "근거 있음"(confidenceLabel).
|
||||
await openReviewTab(page, "워크시트");
|
||||
const worksheetPanel = page.locator("#sr-panel-worksheet");
|
||||
await expect(worksheetPanel.locator(".sr-ws-badge").first()).toHaveText(
|
||||
"근거 있음",
|
||||
);
|
||||
await expect(worksheetPanel).not.toContainText("medium");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 리뷰 세 탭을 순회해도 화면 어디에도 raw UUID가 노출되지 않는다
|
||||
test("리뷰 세 탭 어디에도 세션 raw UUID가 노출되지 않는다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const uuid = "e2e0a4c1-6c1b-4f0e-9a2e-b6f4f5f6a7b8";
|
||||
await routeFilledSessionReview(page, uuid);
|
||||
|
||||
await page.goto(`/learn/session/${uuid}/review`);
|
||||
|
||||
const root = page.locator(".sr-root");
|
||||
await expect(page.locator(".sr-card--transcript")).toBeVisible();
|
||||
await expect(root).not.toContainText(uuid);
|
||||
await openReviewTab(page, "피드백");
|
||||
await expect(page.locator(".sr-card--rubric")).toBeVisible();
|
||||
await expect(root).not.toContainText(uuid);
|
||||
await openReviewTab(page, "워크시트");
|
||||
await expect(page.locator(".sr-card--worksheet")).toBeVisible();
|
||||
await expect(root).not.toContainText(uuid);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 워크시트 항목을 고쳐 저장하고 제출본 상태를 확인한다
|
||||
test("워크시트 항목을 수정해 저장하면 학습자 제출본으로 반영된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-worksheet-save-ok";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
const editedValue =
|
||||
"엄마의 비교 발화 직후 자기비난과 분노가 동시에 올라오는 패턴.";
|
||||
let savedBody: Record<string, unknown> | null = null;
|
||||
await page.route(
|
||||
`**/api/sessions/${sessionId}/review/worksheet`,
|
||||
async (route) => {
|
||||
savedBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const sections = savedBody.sections;
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
status: "saved_by_learner",
|
||||
generatedBy: "rule-based transcript extractor",
|
||||
savedAt: "2026-06-27 10:30",
|
||||
sections,
|
||||
limitations: savedBody.limitations ?? [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "워크시트");
|
||||
|
||||
// 초안 상태: 변경 전에는 저장 버튼이 "저장됨"으로 비활성화되어 있다.
|
||||
const worksheetPanel = page.locator("#sr-panel-worksheet");
|
||||
await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText(
|
||||
"축어록 기반 자동 초안",
|
||||
);
|
||||
await expect(
|
||||
worksheetPanel.getByRole("button", { name: "저장됨" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await page.getByLabel("촉발 장면 반복 비교").fill(editedValue);
|
||||
const saveButton = worksheetPanel.getByRole("button", { name: "저장" });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText(
|
||||
"저장된 학습자 제출본",
|
||||
);
|
||||
await expect(
|
||||
worksheetPanel.getByRole("button", { name: "저장됨" }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel("촉발 장면 반복 비교")).toHaveValue(editedValue);
|
||||
expect(savedBody).not.toBeNull();
|
||||
const sentSections = JSON.stringify(savedBody!.sections);
|
||||
expect(sentSections).toContain(editedValue);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 워크시트 저장에 실패해도 입력을 잃지 않고 오류를 보고 재시도한다
|
||||
test("워크시트 저장 실패 시 오류 문구와 함께 입력이 보존된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-worksheet-save-fail";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
await page.route(`**/api/sessions/${sessionId}/review/worksheet`, (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "worksheet backend down" }, 500)),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "워크시트");
|
||||
|
||||
const editedValue = "저장 실패 검증용 수정 내용.";
|
||||
await page.getByLabel("핵심 정서 분노와 수치심").fill(editedValue);
|
||||
const worksheetPanel = page.locator("#sr-panel-worksheet");
|
||||
await worksheetPanel.getByRole("button", { name: "저장" }).click();
|
||||
|
||||
await expect(worksheetPanel.locator(".sr-ws-save-error")).toHaveText(
|
||||
"워크시트를 저장하지 못했습니다.",
|
||||
);
|
||||
// 초안 상태 유지 + 입력 보존 + 재시도 가능.
|
||||
await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText(
|
||||
"축어록 기반 자동 초안",
|
||||
);
|
||||
await expect(page.getByLabel("핵심 정서 분노와 수치심")).toHaveValue(
|
||||
editedValue,
|
||||
);
|
||||
await expect(
|
||||
worksheetPanel.getByRole("button", { name: "저장" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 워크시트 항목을 비웠다가 다시 쓰면 신뢰도 배지가 빈칸→초안으로 바뀐다
|
||||
test("워크시트 값을 비우면 빈칸, 다시 쓰면 초안 신뢰도 배지로 바뀐다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-worksheet-confidence";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "워크시트");
|
||||
|
||||
const item = page.locator(".sr-ws-item", { hasText: "반복 비교" });
|
||||
await expect(item.locator(".sr-ws-badge")).toHaveText("근거 있음");
|
||||
|
||||
await page.getByLabel("촉발 장면 반복 비교").fill("");
|
||||
await expect(item.locator(".sr-ws-badge")).toHaveText("빈칸");
|
||||
|
||||
await page.getByLabel("촉발 장면 반복 비교").fill("직접 다시 정리한 촉발 장면.");
|
||||
await expect(item.locator(".sr-ws-badge")).toHaveText("초안");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 회기 종료 후(post) 자기평가 세 축을 잠그고 잠금 표시를 확인한다
|
||||
test("자기평가 세 축을 잠그면 잠금 상태와 잠긴 점수가 표시된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-alliance-lock";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
let locked = false;
|
||||
let createBody: Record<string, unknown> | null = null;
|
||||
await page.route(
|
||||
`**/api/sessions/${sessionId}/alliance-pulses`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
createBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
locked = true;
|
||||
await route.fulfill(
|
||||
jsonRoute({ pulse_id: "pulse-locked-1", status: "awaiting_agents" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: locked
|
||||
? [
|
||||
{
|
||||
pulse_id: "pulse-locked-1",
|
||||
checkpoint: "post",
|
||||
status: "awaiting_agents",
|
||||
learner_locked_at: "2026-06-27T10:10:00+00:00",
|
||||
revealed_at: null,
|
||||
error_code: null,
|
||||
self_scores: { goal: 1, task: 0.75, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
const apCard = page.locator(".ap-card");
|
||||
await expect(apCard.locator(".ap-self-form")).toBeVisible();
|
||||
const submitButton = apCard.getByRole("button", {
|
||||
name: "내 판단 잠그고 관점 비교",
|
||||
});
|
||||
// 세 축과 근거 발화를 채우기 전에는 잠글 수 없다.
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// 축별 5점 척도: 목표=5, 과업=4, 유대=3.
|
||||
const axes = apCard.locator(".ap-axis");
|
||||
await axes.nth(0).locator(".ap-scale__option").nth(4).click();
|
||||
await axes.nth(1).locator(".ap-scale__option").nth(3).click();
|
||||
await axes.nth(2).locator(".ap-scale__option").nth(2).click();
|
||||
// 근거 장면 선택 패널을 열어 첫 발화를 근거로 고른다.
|
||||
const picker = apCard.locator("details.ap-evidence-picker");
|
||||
await picker.locator("summary").click();
|
||||
await picker.locator(".ap-evidence-picker__list label").first().click();
|
||||
await expect(picker.locator("summary")).toContainText("1개 선택");
|
||||
|
||||
await expect(submitButton).toBeEnabled();
|
||||
await submitButton.click();
|
||||
|
||||
// 잠금 표시: 상태 배지 + 잠긴 점수(수정 불가 안내).
|
||||
await expect(apCard.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||
await expect(apCard.getByText("관점 분석 중")).toBeVisible();
|
||||
const lockedScores = apCard.locator(".ap-locked-scores");
|
||||
await expect(lockedScores).toContainText("100점");
|
||||
await expect(lockedScores).toContainText("75점");
|
||||
await expect(lockedScores).toContainText("50점");
|
||||
expect(createBody).not.toBeNull();
|
||||
expect(createBody!.checkpoint).toBe("post");
|
||||
expect(Array.isArray(createBody!.evidence_turn_ids)).toBe(true);
|
||||
expect((createBody!.evidence_turn_ids as string[]).length).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 이미 잠긴 자기평가가 있는 상태에서 다시 제출하면 저장된 잠금 기록이 표시된다
|
||||
test("이미 잠긴 자기평가(409)는 저장된 잠금 기록으로 되돌아간다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-alliance-lock-conflict";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
let conflicted = false;
|
||||
await page.route(
|
||||
`**/api/sessions/${sessionId}/alliance-pulses`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
conflicted = true;
|
||||
await route.fulfill(
|
||||
jsonRoute({ detail: "alliance pulse already locked" }, 409),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: conflicted
|
||||
? [
|
||||
{
|
||||
pulse_id: "pulse-existing-1",
|
||||
checkpoint: "post",
|
||||
status: "awaiting_agents",
|
||||
learner_locked_at: "2026-06-27T09:00:00+00:00",
|
||||
revealed_at: null,
|
||||
error_code: null,
|
||||
// 서버에 저장돼 있던 기존 점수(로컬 입력과 다른 값).
|
||||
self_scores: { goal: 0.25, task: 0.25, bond: 0.25 },
|
||||
measurements: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
const apCard = page.locator(".ap-card");
|
||||
const axes = apCard.locator(".ap-axis");
|
||||
await axes.nth(0).locator(".ap-scale__option").nth(4).click();
|
||||
await axes.nth(1).locator(".ap-scale__option").nth(4).click();
|
||||
await axes.nth(2).locator(".ap-scale__option").nth(4).click();
|
||||
const picker = apCard.locator("details.ap-evidence-picker");
|
||||
await picker.locator("summary").click();
|
||||
await picker.locator(".ap-evidence-picker__list label").first().click();
|
||||
await apCard
|
||||
.getByRole("button", { name: "내 판단 잠그고 관점 비교" })
|
||||
.click();
|
||||
|
||||
// 로컬 입력(100점)이 아니라 서버에 잠긴 기존 기록(25점)이 표시되어야 한다.
|
||||
await expect(apCard.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||
const lockedScores = apCard.locator(".ap-locked-scores");
|
||||
await expect(lockedScores).toContainText("25점");
|
||||
await expect(lockedScores).not.toContainText("100점");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 '내 발화만' 필터를 켠 채 좋았던 순간 앵커로 자기 발화에 이동한다
|
||||
test("좋았던 순간 앵커로 이동해도 학습자 발화면 내 발화만 필터가 유지된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-good-moment-anchor";
|
||||
await routeFilledSessionReview(page, sessionId);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await page
|
||||
.locator(".sr-tx__filters")
|
||||
.getByRole("button", { name: "내 발화만" })
|
||||
.click();
|
||||
await expect(page.locator(".sr-turn")).toHaveCount(3);
|
||||
|
||||
// '방어를 낮춘 감정 반영'은 학습자 발화 t2(05:04)로 점프한다.
|
||||
await openReviewTab(page, "피드백");
|
||||
await page
|
||||
.locator(".sr-card--good")
|
||||
.getByRole("button", { name: "05:04 발화로 이동" })
|
||||
.click();
|
||||
|
||||
// 학습자 발화 점프이므로 필터는 해제되지 않고 유지된다.
|
||||
await expect(
|
||||
page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator(".sr-turn")).toHaveCount(3);
|
||||
const activeTurn = page.locator(".sr-turn--active");
|
||||
await expect(activeTurn).toHaveCount(1);
|
||||
await expect(activeTurn).toContainText("스스로를 의심하게 됐던 걸까요?");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 성장 포인트를 읽을 때 축어록에 없는 턴으로는 이동 버튼이 생기지 않는다
|
||||
test("성장 포인트의 대상 턴이 축어록에 없으면 이동 버튼을 만들지 않는다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "uc-growth-missing-anchor";
|
||||
const response = filledReviewResponse(sessionId);
|
||||
// 첫 번째 성장 포인트의 jumpTo가 축어록에 존재하지 않는 턴을 가리키는 응답.
|
||||
response.growthPoints[0] = {
|
||||
...response.growthPoints[0],
|
||||
jumpTo: "turn-not-in-transcript",
|
||||
};
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
|
||||
route.fulfill(jsonRoute(response)),
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "피드백");
|
||||
|
||||
const growthCard = page.locator(".sr-card--growth");
|
||||
// 본문은 그대로 보이되(JumpablePoint), 존재하지 않는 턴으로의 앵커 버튼은 없다.
|
||||
const brokenPoint = growthCard.locator(".sr-point", {
|
||||
hasText: "개입 전 준비도 확인",
|
||||
});
|
||||
await expect(brokenPoint).toBeVisible();
|
||||
await expect(brokenPoint.locator(".sr-point__at")).toHaveCount(0);
|
||||
// 정상 앵커(t3 → 12:41)는 여전히 제공된다.
|
||||
await expect(
|
||||
growthCard.getByRole("button", { name: "12:41 발화로 이동" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 평가 실패 회기에서 AI 평가를 재시도하고 진행 표시와 성공 반영을 본다
|
||||
test("AI 평가 재시도 중 진행 라벨을 보이고 성공하면 리뷰에 반영된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
const sessionId = "uc-eval-retry-success";
|
||||
let retrySucceeded = false;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
const ready = filledReviewResponse(sessionId);
|
||||
const failed = {
|
||||
...ready,
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: engine_error: evaluator timeout",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
};
|
||||
await route.fulfill(jsonRoute(retrySucceeded ? ready : failed));
|
||||
});
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
|
||||
let releaseRetry: (() => void) | null = null;
|
||||
const retryGate = new Promise<void>((resolve) => {
|
||||
releaseRetry = resolve;
|
||||
});
|
||||
let retryBody: Record<string, unknown> | null = null;
|
||||
await page.route(
|
||||
`**/api/eval/sessions/${sessionId}/reevaluate`,
|
||||
async (route) => {
|
||||
retryBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await retryGate;
|
||||
retrySucceeded = true;
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
loop: "deep",
|
||||
session_id: sessionId,
|
||||
stage: "정리",
|
||||
scope: "session_end",
|
||||
turns_evaluated: 6,
|
||||
distribution: {},
|
||||
theory_mode: "humanistic",
|
||||
strengths: [],
|
||||
improvements: [],
|
||||
intent_deviations: [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||
|
||||
// 재시도 진행 중에는 실행 중 라벨로 바뀌고 눌 수 없다(응답은 gate로 붙잡아 둔 상태).
|
||||
const runningButton = page.getByRole("button", { name: "AI 평가 실행 중" });
|
||||
await expect(runningButton).toBeVisible();
|
||||
await expect(runningButton).toBeDisabled();
|
||||
releaseRetry?.();
|
||||
|
||||
// 성공하면 리뷰를 다시 불러와 완료 상태와 실제 평가 내용이 반영된다.
|
||||
await expect(page.getByText("평가 완료")).toBeVisible();
|
||||
await expect(page.getByText("평가 실패")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("관계 신호가 안정적으로 회복된 회기"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "AI 평가 재시도" }),
|
||||
).toHaveCount(0);
|
||||
expect(retryBody).not.toBeNull();
|
||||
expect(retryBody!.scope).toBe("session_end");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 재시도했지만 엔진이 오류를 돌려주면 원문 오류 대신 정제된 안내를 본다
|
||||
test("재시도 응답에 엔진 오류가 담기면 원문 대신 정제된 안내를 보여준다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
const sessionId = "uc-eval-retry-engine-error";
|
||||
let reviewRequests = 0;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
reviewRequests += 1;
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
...filledReviewResponse(sessionId),
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: engine_error: evaluator timeout",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
// HTTP 200이지만 결과 본문에 엔진 오류가 담긴 경우(503 실패 경로는 기존 스펙이 커버).
|
||||
await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
loop: "deep",
|
||||
session_id: sessionId,
|
||||
stage: "정리",
|
||||
scope: "session_end",
|
||||
turns_evaluated: 0,
|
||||
distribution: {},
|
||||
theory_mode: "humanistic",
|
||||
strengths: [],
|
||||
improvements: [],
|
||||
intent_deviations: [],
|
||||
error: "engine_error: evaluator crashed",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
const requestsBeforeRetry = reviewRequests;
|
||||
|
||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("evaluator crashed")).toHaveCount(0);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "AI 평가 재시도" }),
|
||||
).toBeEnabled();
|
||||
// 실패한 재시도는 리뷰를 다시 불러오지 않는다.
|
||||
expect(reviewRequests).toBe(requestsBeforeRetry);
|
||||
});
|
||||
|
||||
// usecase: 교수자가 학습자 워크시트 초안을 읽기 전용으로 검토한다
|
||||
test("교수자 화면에서는 워크시트가 검토 전용 읽기 상태로 표시된다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
const sessionId = "uc-worksheet-readonly";
|
||||
const response = filledReviewResponse(sessionId);
|
||||
response.teacherReview = {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
};
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
|
||||
route.fulfill(jsonRoute(response)),
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "워크시트");
|
||||
|
||||
const worksheetPanel = page.locator("#sr-panel-worksheet");
|
||||
await expect(worksheetPanel.locator(".sr-ws-readonly")).toHaveText(
|
||||
"검토 전용",
|
||||
);
|
||||
await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText(
|
||||
"축어록 자동 초안 읽기 전용",
|
||||
);
|
||||
// 저장 버튼이 없고, 입력창은 읽기 전용이다.
|
||||
await expect(
|
||||
worksheetPanel.getByRole("button", { name: /저장/ }),
|
||||
).toHaveCount(0);
|
||||
const firstInput = worksheetPanel.locator(".sr-ws-input").first();
|
||||
await expect(firstInput).toHaveAttribute("readonly", "");
|
||||
// 읽기 전용이어도 근거 발화 이동은 가능하다.
|
||||
await expect(
|
||||
worksheetPanel.getByRole("button", { name: "내담자 근거 발화로 이동" }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
453
apps/web/e2e/uc-session-timer.spec.ts
Normal file
453
apps/web/e2e/uc-session-timer.spec.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/* =====================================================================
|
||||
uc-session-timer.spec.ts — 회기 시간 수명주기(60분·10분 전 알람·만료·유예) 유스케이스 E2E
|
||||
|
||||
목적: 시간 기반 회기(회의 P1)의 사용자 여정을 화면 계약 기준으로 검증한다.
|
||||
(경과 타이머, 10분 전 경고 바, 만료 정리 다이얼로그, 유예 계속 진행,
|
||||
유예 종료 후 발화 거부 안내, 종료 확정→리뷰 이동, 종료 실패 처리)
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx —
|
||||
sessionLimitSeconds/sessionWarningSeconds(743~766), 알람 바(2845~2869),
|
||||
만료 다이얼로그(4069~4122, 포커스 트랩 2247~2281),
|
||||
session_time_over 거부(1440~1444), handleEnd(2284~2314).
|
||||
detail.duration_limit_seconds / warning_before_end_seconds /
|
||||
elapsedFromSession(195)의 restore 경로(1055~1073)로 시간 상태를 만든다.
|
||||
|
||||
주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
|
||||
중복 회피: session-layout.spec.ts(뷰포트 유지), session-persistence.spec.ts
|
||||
(@single-run 실 DB), uc-session-conversation.spec.ts(대화 왕복)와 각도 분리.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SESSION_ID = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
interface TimerFixtureOptions {
|
||||
/** 회기 시작이 지금으로부터 몇 초 전인가 (restore 경로의 시간 상태를 결정) */
|
||||
startedAgoSeconds: number;
|
||||
durationLimitSeconds?: number;
|
||||
warningBeforeEndSeconds?: number;
|
||||
status?: "active" | "ended";
|
||||
/** POST /sessions/{id}/end 응답 상태코드 (기본 200) */
|
||||
endStatus?: number;
|
||||
}
|
||||
|
||||
interface EndCallTracker {
|
||||
readonly calls: number;
|
||||
}
|
||||
|
||||
async function routeTimedSession(
|
||||
page: Page,
|
||||
options: TimerFixtureOptions,
|
||||
): Promise<EndCallTracker> {
|
||||
const {
|
||||
startedAgoSeconds,
|
||||
durationLimitSeconds = 3600,
|
||||
warningBeforeEndSeconds = 600,
|
||||
status = "active",
|
||||
endStatus = 200,
|
||||
} = options;
|
||||
const startedAt = new Date(Date.now() - startedAgoSeconds * 1000);
|
||||
const endedAt =
|
||||
status === "ended"
|
||||
? new Date(startedAt.getTime() + durationLimitSeconds * 1000)
|
||||
: null;
|
||||
|
||||
// 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-0000000uct01",
|
||||
email: "uc.timer@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,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
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-timer-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status,
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.34,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: endedAt ? endedAt.toISOString() : null,
|
||||
review_ready: status === "ended",
|
||||
duration_limit_seconds: durationLimitSeconds,
|
||||
warning_before_end_seconds: warningBeforeEndSeconds,
|
||||
goal_stages: [],
|
||||
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: 3, max: 3 },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const endState = { calls: 0 };
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/end`, async (route) => {
|
||||
endState.calls += 1;
|
||||
if (endStatus >= 400) {
|
||||
await route.fulfill(jsonRoute({ detail: "평가 저장소 연결 실패" }, endStatus));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({ session_id: SESSION_ID, status: "ended", review_ready: false }),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
get calls() {
|
||||
return endState.calls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openSession(page: Page) {
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
}
|
||||
|
||||
const timebar = (page: Page) => page.locator(".sx-timebar");
|
||||
const endDialog = (page: Page) => page.getByRole("dialog", { name: /회기/ });
|
||||
const keepTalkingButton = (page: Page) =>
|
||||
page.getByRole("button", { name: "마무리 인사 나누기" });
|
||||
const confirmEndButton = (page: Page) =>
|
||||
page.getByRole("button", { name: "종료하고 리뷰 보기" }).last();
|
||||
|
||||
test.describe("회기 시간 수명주기 — 알람·만료·유예·종료", () => {
|
||||
// usecase: 회기 초반에는 경고 없이 경과 시간만 흐른다
|
||||
test("회기 초반에는 시간 알람 바가 노출되지 않는다", async ({ page }) => {
|
||||
await routeTimedSession(page, { startedAgoSeconds: 30 });
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 경고 시점 이전(잔여 시간이 경고 창 밖)에는 아직 경고가 뜨지 않는다.
|
||||
// 주의: 화면 타이머는 restore 후에도 1초씩 실시간 진행한다(Session.tsx 657~661) —
|
||||
// 경계 1초 차 fixture 는 페이지 로딩 중에 경계를 넘어 버리므로 여유를 둔다.
|
||||
test("경고 창 경계 이전에는 알람이 조기 노출되지 않는다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 660,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 종료 10분 전이 되면 정리를 유도하는 경고가 강제 노출된다
|
||||
test("종료 10분 전 경고 바가 role=alert 로 나타난다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 540,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
const bar = timebar(page);
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar).toHaveAttribute("role", "alert");
|
||||
await expect(bar).toContainText("종료 9분 전");
|
||||
await expect(bar).toContainText("남은 시간 동안 오늘 나눈 이야기를 정리해 보세요.");
|
||||
// 만료 전이므로 종료 바로가기 버튼은 아직 없다.
|
||||
await expect(bar.getByRole("button", { name: "종료하고 리뷰 보기" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 잔여 시간 분 표기는 올림으로 계산된다 — 잔여 100초(1.67분)는 "2분 전".
|
||||
// 주의: 타이머가 실시간 진행하므로(위와 동일) 61초 같은 경계값은 로딩 중 60초
|
||||
// 이하로 떨어져 "1분 전"이 된다. 올림 계약(Math.ceil, Session.tsx 2852)은
|
||||
// 분 경계에서 충분히 떨어진 잔여 100초로 검증한다.
|
||||
test("경고 분 표기는 잔여 시간을 올림해 표시한다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 100,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toContainText("종료 2분 전");
|
||||
});
|
||||
|
||||
// usecase: 시간이 다 되면 정리 유도 다이얼로그가 자동으로 열린다
|
||||
test("시간 만료 시 정리 다이얼로그가 자동으로 열린다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
const dialog = endDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("회기 시간이 끝났어요");
|
||||
await expect(dialog).toContainText("실제 상담처럼 시간이 회기를 마무리합니다.");
|
||||
});
|
||||
|
||||
// usecase: 만료 다이얼로그가 열리면 첫 포커스는 계속 진행(취소) 버튼에 놓인다
|
||||
test("만료 다이얼로그의 첫 포커스는 '마무리 인사 나누기'다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 키보드 사용자는 Tab 이동이 다이얼로그 두 버튼 밖으로 새지 않는다
|
||||
test("만료 다이얼로그 Tab 순환이 두 버튼 사이에 갇힌다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(confirmEndButton(page)).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(confirmEndButton(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 마지막 인사를 나누고 싶으면 다이얼로그를 닫고 계속 이야기할 수 있다(유예)
|
||||
test("'마무리 인사 나누기'로 다이얼로그를 닫고 입력을 계속 쓸 수 있다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await keepTalkingButton(page).click();
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
|
||||
// 만료 알람 바는 유예 중에도 종료를 계속 안내한다.
|
||||
const bar = timebar(page);
|
||||
await expect(bar).toContainText("회기 시간이 끝났어요");
|
||||
await expect(bar).toContainText("정리 시간이 지나면 새 발화가 제한됩니다.");
|
||||
await expect(bar.getByRole("button", { name: "종료하고 리뷰 보기" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 유예 중에도 언제든 알람 바에서 종료 절차를 다시 시작할 수 있다
|
||||
test("알람 바의 종료 버튼이 만료 다이얼로그를 다시 연다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await keepTalkingButton(page).click();
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
|
||||
await timebar(page).getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: Escape 키로도 다이얼로그를 닫아 유예를 선택할 수 있다
|
||||
test("Escape 키가 만료 다이얼로그를 닫는다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 정리 유예까지 끝난 뒤의 발화는 서버가 거부하고 화면은 이유를 정직하게 알린다
|
||||
test("유예 종료 후 발화는 session_time_over 안내로 거부된다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "session_time_over" }, 409)),
|
||||
);
|
||||
await openSession(page);
|
||||
await keepTalkingButton(page).click();
|
||||
|
||||
const composer = page.getByLabel("학습자 발화 입력");
|
||||
await composer.fill("마지막으로 한 가지만 더 여쭤봐도 될까요?");
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("회기 시간이 모두 지나 새 발화를 보낼 수 없습니다", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 종료를 확정하면 축어록이 저장되고 리뷰 화면으로 이동한다
|
||||
test("종료 확정 시 end 호출 후 리뷰 경로로 이동한다", async ({ page }) => {
|
||||
const endCalls = await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await confirmEndButton(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${SESSION_ID}/review$`));
|
||||
expect(endCalls.calls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 종료 저장이 실패하면 리뷰로 넘어가지 않고 실패 사실을 알린다
|
||||
test("종료 실패 시 리뷰 이동 없이 오류를 안내한다", async ({ page }) => {
|
||||
const endCalls = await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
endStatus: 500,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await confirmEndButton(page).click();
|
||||
await expect(
|
||||
page.getByText("회기 종료에 실패했습니다", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${SESSION_ID}$`));
|
||||
expect(endCalls.calls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 이미 종료된 회기를 다시 열면 만료 경고나 다이얼로그가 반복되지 않는다
|
||||
test("종료된 회기 재열람 시 알람 바와 다이얼로그가 없다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 4000,
|
||||
durationLimitSeconds: 3600,
|
||||
status: "ended",
|
||||
});
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
// 종료 상태 화면이 완전히 그려질 때까지 축어록 존재로 대기한다.
|
||||
await expect(page.locator(".sx-utt").first()).toBeVisible();
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 시간이 남은 회기에서 학습자가 직접 종료를 선택하면 일반 확인 문구가 뜬다
|
||||
test("시간 내 수동 종료 다이얼로그는 일반 확인 문구를 쓴다", async ({ page }) => {
|
||||
await routeTimedSession(page, { startedAgoSeconds: 600 });
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: "회기 종료" }).first().click();
|
||||
const dialog = endDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("회기를 종료할까요?");
|
||||
await expect(dialog).toContainText(
|
||||
"종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "계속 진행" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
667
apps/web/e2e/uc-settings-consent.spec.ts
Normal file
667
apps/web/e2e/uc-settings-consent.spec.ts
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
/* =====================================================================
|
||||
uc-settings-consent.spec.ts — 설정·동의 관리 유스케이스 여정.
|
||||
|
||||
목적: 학습자가 /settings 에서 실습 동의를 관리(2단계 철회 확인 → 확정,
|
||||
철회 후 재동의), 아바타 교체, 표시 이름 수정(빈값·과도한 길이 거절),
|
||||
음성 프리셋 변경·저장, 지원 티켓 확인·작성까지 실제 사용자 행동 단위로
|
||||
검증한다. 저장 실패 시 기존 값 보존도 함께 본다.
|
||||
|
||||
근거: apps/web/src/pages/Settings.tsx 의 실제 UI 계약(버튼 문구·testid·
|
||||
role)과 apps/web/src/lib/api.ts / api.gen.ts 의 응답 타입.
|
||||
중복 회피: settings.spec.ts(실 API 저장 왕복), full-sweep-settings.spec.ts
|
||||
(가드·빈 상태·테마·실 API 아바타 최초 업로드·실 API 동의 왕복)와 달리
|
||||
여기서는 전부 route fixture 로 각 단계의 UI 계약을 고립 검증한다.
|
||||
|
||||
실 API 없이 route fixture 만으로 동작하며, 실제 AI 엔진 턴 생성은 없다.
|
||||
===================================================================== */
|
||||
|
||||
import { test, expect, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const CONSENT_AT = 1785542400; // 2026-08-01T00:00:00Z (초 단위 epoch)
|
||||
const REACCEPT_AT = 1787000000;
|
||||
const TERMS_AT = 1780000000;
|
||||
|
||||
/** 1x1 PNG — 아바타 업로드용 최소 픽셀. */
|
||||
const PNG_1X1_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||
);
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
role?: "learner" | "teacher";
|
||||
consentAt?: number | null;
|
||||
avatarUrl?: string;
|
||||
tickets?: Array<Record<string, unknown>>;
|
||||
/** PATCH /users/me 응답 상태 — 422 등 거절 시나리오용. */
|
||||
profilePatchStatus?: number;
|
||||
/** PATCH /users/me/preferences 응답 상태. */
|
||||
preferencesPatchStatus?: number;
|
||||
/** POST /users/me/avatar 응답 상태. */
|
||||
avatarUploadStatus?: number;
|
||||
/** DELETE /auth/consent 응답 상태. */
|
||||
consentWithdrawStatus?: number;
|
||||
}
|
||||
|
||||
interface FixtureState {
|
||||
consentDeletes: number;
|
||||
consentPosts: number;
|
||||
}
|
||||
|
||||
function buildProfile(opts: FixtureOptions) {
|
||||
return {
|
||||
user_id: "e2e-uc-settings-1",
|
||||
email: "uc.settings@hs.ac.kr",
|
||||
display_name: "설정 유스케이스 학습자",
|
||||
legal_name: "설정 유스케이스 학습자",
|
||||
nickname: "설정학습자",
|
||||
self_introduction: "",
|
||||
role: opts.role ?? "learner",
|
||||
cohort_ids: [],
|
||||
affiliation: "한신대학교",
|
||||
department: "상담심리학과",
|
||||
grade_level: "3학년",
|
||||
phone: "010-0000-0000",
|
||||
contact_address: "경기도 오산시",
|
||||
avatar_url: opts.avatarUrl ?? "/static/avatars/uc-old-avatar.png",
|
||||
onboarding_required: false,
|
||||
onboarding_completed_at: TERMS_AT,
|
||||
terms_version: "2026-06-01",
|
||||
terms_agreed_at: TERMS_AT,
|
||||
privacy_version: "2026-06-01",
|
||||
privacy_agreed_at: TERMS_AT,
|
||||
};
|
||||
}
|
||||
|
||||
const VOICE_PRESETS = [
|
||||
{
|
||||
id: "soft-young-fem",
|
||||
name: "차분한 목소리",
|
||||
voice_id: "ko-soft-01",
|
||||
desc: "낮은 톤의 안정적인 발화",
|
||||
persona_hint: "차분한 20대 내담자",
|
||||
},
|
||||
{
|
||||
id: "bright-mid-fem",
|
||||
name: "밝은 목소리",
|
||||
voice_id: "ko-bright-02",
|
||||
desc: "밝고 빠른 발화",
|
||||
persona_hint: "활발한 30대 내담자",
|
||||
},
|
||||
];
|
||||
|
||||
function buildPreferences() {
|
||||
return {
|
||||
theme: "light",
|
||||
voice_preset_id: VOICE_PRESETS[0].id,
|
||||
voice_rate: 1,
|
||||
notifications: {
|
||||
session_done: true,
|
||||
safety_signal: true,
|
||||
learner_progress: true,
|
||||
account_approval: true,
|
||||
product_news: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTicket(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
ticket_id: "uc-ticket-1",
|
||||
status: "open",
|
||||
category: "session_review",
|
||||
priority: "normal",
|
||||
subject: "리뷰 결과가 열리지 않아요",
|
||||
resolution_note: "",
|
||||
resolved_at: null,
|
||||
assigned_group: "",
|
||||
source_path: "/settings",
|
||||
created_at: TERMS_AT,
|
||||
updated_at: TERMS_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** catch-all → 개별 fixture(나중 등록이 우선) 순서로 설정 화면 전체를 목킹한다. */
|
||||
async function mountSettingsFixtures(
|
||||
page: Page,
|
||||
opts: FixtureOptions = {},
|
||||
): Promise<FixtureState> {
|
||||
const state: FixtureState = { consentDeletes: 0, consentPosts: 0 };
|
||||
const consentAt = opts.consentAt === undefined ? CONSENT_AT : opts.consentAt;
|
||||
const role = opts.role ?? "learner";
|
||||
const profile = buildProfile(opts);
|
||||
|
||||
await routeUnmockedApi(page);
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: profile.user_id,
|
||||
email: profile.email,
|
||||
role,
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: consentAt,
|
||||
onboarding_completed_at: TERMS_AT,
|
||||
nickname: profile.nickname,
|
||||
display_name: profile.display_name,
|
||||
self_introduction: "",
|
||||
avatar_url: profile.avatar_url,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/consent", (route: Route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "DELETE") {
|
||||
state.consentDeletes += 1;
|
||||
const status = opts.consentWithdrawStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return route.fulfill(jsonRoute({ detail: "동의 철회 처리에 실패했습니다. (e2e fixture)" }, status));
|
||||
}
|
||||
return route.fulfill(jsonRoute({ consent_at: null }));
|
||||
}
|
||||
if (method === "POST") {
|
||||
state.consentPosts += 1;
|
||||
return route.fulfill(jsonRoute({ consent_at: REACCEPT_AT }));
|
||||
}
|
||||
return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405));
|
||||
});
|
||||
|
||||
await page.route("**/api/users/me", (route: Route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return route.fulfill(jsonRoute(profile));
|
||||
if (method === "PATCH") {
|
||||
const status = opts.profilePatchStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ detail: "표시 이름이 유효하지 않습니다. (e2e fixture)" }, status),
|
||||
);
|
||||
}
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
return route.fulfill(jsonRoute({ ...profile, ...body }));
|
||||
}
|
||||
return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405));
|
||||
});
|
||||
|
||||
await page.route("**/api/users/me/preferences", (route: Route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return route.fulfill(jsonRoute(buildPreferences()));
|
||||
if (method === "PATCH") {
|
||||
const status = opts.preferencesPatchStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ detail: "환경 설정 저장에 실패했습니다. (e2e fixture)" }, status),
|
||||
);
|
||||
}
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
return route.fulfill(jsonRoute({ ...buildPreferences(), ...body }));
|
||||
}
|
||||
return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405));
|
||||
});
|
||||
|
||||
await page.route("**/api/users/me/voice-presets", (route) =>
|
||||
route.fulfill(jsonRoute(VOICE_PRESETS)),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/support-tickets", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
source: "database",
|
||||
durable: true,
|
||||
generated_at: TERMS_AT,
|
||||
tickets: opts.tickets ?? [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/me/avatar", (route: Route) => {
|
||||
const status = opts.avatarUploadStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return route.fulfill(jsonRoute({ detail: "업로드 실패 (e2e fixture)" }, status));
|
||||
}
|
||||
return route.fulfill(jsonRoute({ avatar_url: "/static/avatars/uc-new-avatar.png" }));
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** /settings 진입 후 프로필·환경설정 도착(저장 버튼 렌더)까지 대기. */
|
||||
async function openLoadedSettings(page: Page) {
|
||||
await page.goto("/settings");
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.locator(".vg-set")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("#set-account .vg-set__foot .vg-btn")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** React 상태 반영 두 프레임 대기 — 임의 sleep 대신 rAF 2회. */
|
||||
async function waitTwoFrames(page: Page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("설정·동의 관리 유스케이스", () => {
|
||||
// usecase: 학습자가 동의 섹션에서 실습 동의 시각과 약관·개인정보 동의 이력을 확인한다
|
||||
test("학습자가 동의 섹션에서 실습 동의 시각과 약관·개인정보 동의 이력을 확인한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
// 실습 동의: 연도까지 포함한 동의 시각이 표시된다(감사 추적 계약).
|
||||
const practice = consent.getByTestId("settings-consent-practice");
|
||||
await expect(practice).toContainText("동의 시각");
|
||||
await expect(practice).toContainText("2026");
|
||||
// 약관·개인정보: 버전과 동의 시각이 함께 표시된다.
|
||||
await expect(consent.getByText("이용약관")).toBeVisible();
|
||||
await expect(consent.getByText("개인정보 처리방침")).toBeVisible();
|
||||
await expect(consent.getByText(/버전 2026-06-01/).first()).toBeVisible();
|
||||
// 동의된 상태에서는 철회 진입 버튼이 노출된다.
|
||||
await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 실습 동의 철회 버튼을 눌러 1단계 확인 안내를 연다(아직 철회 요청은 전송되지 않는다)
|
||||
test("철회 1단계 — 철회 버튼 클릭 시 확인 안내만 뜨고 철회 요청은 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||
|
||||
// 인라인 확인 블록: 경고 문구 + 철회 확정/취소.
|
||||
await expect(consent.getByText("정말 철회할까요?")).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "취소" })).toBeVisible();
|
||||
// 1단계에서는 DELETE /auth/consent 가 나가지 않고 동의 시각도 유지된다.
|
||||
expect(state.consentDeletes).toBe(0);
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 철회 확인 단계에서 취소를 눌러 동의를 유지한다
|
||||
test("철회 확인 단계에서 취소하면 동의가 유지되고 확인 블록이 닫힌다", async ({ page }) => {
|
||||
const state = await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||
await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible();
|
||||
|
||||
await consent.getByRole("button", { name: "취소" }).click();
|
||||
await expect(consent.getByRole("button", { name: "철회 확정" })).toHaveCount(0);
|
||||
await expect(consent.getByText("정말 철회할까요?")).toHaveCount(0);
|
||||
// 동의 상태 그대로: 철회 진입 버튼과 동의 시각이 다시 보인다.
|
||||
await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible();
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||
expect(state.consentDeletes).toBe(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 철회를 확정해 실습 동의를 실제로 철회한다
|
||||
test("철회 확정 시 철회 요청이 전송되고 차단 안내와 다시 동의 버튼이 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||
|
||||
const deletePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "DELETE" &&
|
||||
new URL(response.url()).pathname.endsWith("/auth/consent"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await consent.getByRole("button", { name: "철회 확정" }).click();
|
||||
await deletePromise;
|
||||
expect(state.consentDeletes).toBe(1);
|
||||
|
||||
// 철회됨: 미동의 표기 + 차단 안내(role=status) + 재동의 진입 버튼.
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText(
|
||||
"미동의 상태입니다",
|
||||
);
|
||||
const blockedNotice = consent.getByRole("status");
|
||||
await expect(blockedNotice).toContainText("새 회기 시작과 음성 연습이 차단됩니다");
|
||||
await expect(consent.getByRole("button", { name: "다시 동의" })).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 철회한 학습자가 설정 화면에서 다시 동의해 실습 상태를 복원한다
|
||||
test("철회 후 다시 동의를 누르면 동의가 복원되어 동의 시각이 다시 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||
await consent.getByRole("button", { name: "철회 확정" }).click();
|
||||
await expect(consent.getByRole("button", { name: "다시 동의" })).toBeVisible();
|
||||
|
||||
const postPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname.endsWith("/auth/consent"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await consent.getByRole("button", { name: "다시 동의" }).click();
|
||||
await postPromise;
|
||||
expect(state.consentPosts).toBe(1);
|
||||
|
||||
// 복원: 동의 시각 표기와 철회 진입 버튼이 돌아온다.
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||
await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "다시 동의" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 철회 요청이 서버에서 거절되면 학습자는 오류 안내를 보고 동의 상태를 유지한다
|
||||
test("철회 요청이 실패하면 오류 안내가 뜨고 동의 상태가 보존된다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page, { consentWithdrawStatus: 500 });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||
await consent.getByRole("button", { name: "철회 확정" }).click();
|
||||
|
||||
// 실패: role=alert 경고가 뜨고, 동의 시각 표기는 그대로다.
|
||||
const errorState = consent.locator(".vg-set__state--warn");
|
||||
await expect(errorState).toBeVisible();
|
||||
await expect(errorState).toHaveAttribute("role", "alert");
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||
// 확인 블록이 유지되어 재시도 또는 취소를 이어갈 수 있다.
|
||||
await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "취소" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 동의 섹션을 열면 실습 동의가 학습자 전용임을 확인한다
|
||||
test("교수자 계정에는 철회 버튼 없이 학습자 전용 안내가 표시된다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page, { role: "teacher", consentAt: null });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const consent = page.locator("#set-consent");
|
||||
await expect(consent.getByTestId("settings-consent-practice")).toContainText(
|
||||
"학습자 전용 항목입니다",
|
||||
);
|
||||
await expect(consent.getByText("학습자 전용", { exact: true })).toBeVisible();
|
||||
await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toHaveCount(0);
|
||||
// 비학습자에게는 재동의 블록도 노출되지 않는다.
|
||||
await expect(consent.getByRole("button", { name: "다시 동의" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 아바타 변경 버튼으로 새 이미지를 선택해 저장한다
|
||||
test("아바타 변경 — 파일 선택 시 업로드·프로필 저장 후 미리보기가 새 이미지로 바뀐다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const account = page.locator("#set-account");
|
||||
// 기존 아바타가 있어 버튼 라벨은 '아바타 변경'이다.
|
||||
await expect(account.getByRole("button", { name: "아바타 변경" })).toBeVisible();
|
||||
|
||||
const fileInput = account.locator("input[type='file']");
|
||||
await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp");
|
||||
|
||||
const patchPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/me"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await fileInput.setInputFiles({
|
||||
name: "uc-settings-avatar.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||
});
|
||||
const patchResponse = await patchPromise;
|
||||
// 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다.
|
||||
const patchBody = patchResponse.request().postDataJSON() as { avatar_url?: string };
|
||||
expect(patchBody.avatar_url).toBe("/static/avatars/uc-new-avatar.png");
|
||||
|
||||
// 계정 미리보기가 새 이미지로 전환되고 저장됨 플래시가 뜬다.
|
||||
const preview = account.locator(".vg-set__avatar img");
|
||||
await expect(preview).toBeVisible();
|
||||
expect(await preview.getAttribute("src")).toContain("uc-new-avatar.png");
|
||||
await expect(account.locator(".vg-set__saved")).toContainText("저장됨");
|
||||
});
|
||||
|
||||
// usecase: 아바타 업로드가 실패하면 학습자는 오류 안내를 보고 기존 아바타를 유지한다
|
||||
test("아바타 업로드 실패 시 오류 안내가 뜨고 기존 아바타가 유지된다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page, { avatarUploadStatus: 500 });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const account = page.locator("#set-account");
|
||||
const preview = account.locator(".vg-set__avatar img");
|
||||
const originalSrc = await preview.getAttribute("src");
|
||||
expect(originalSrc).toContain("uc-old-avatar.png");
|
||||
|
||||
await account.locator("input[type='file']").setInputFiles({
|
||||
name: "uc-settings-avatar-fail.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||
});
|
||||
|
||||
// 실패 안내는 role=alert 로 표시되고 파일 규격을 다시 알려준다.
|
||||
const avatarAlert = account.locator(".vg-set__state--warn");
|
||||
await expect(avatarAlert).toBeVisible();
|
||||
await expect(avatarAlert).toHaveAttribute("role", "alert");
|
||||
await expect(avatarAlert).toContainText("아바타 업로드에 실패했습니다");
|
||||
// 기존 아바타는 그대로이고 거짓 저장됨 표시는 없다.
|
||||
expect(await preview.getAttribute("src")).toBe(originalSrc);
|
||||
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다
|
||||
test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mountSettingsFixtures(page, { profilePatchStatus: 422 });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const account = page.locator("#set-account");
|
||||
const nameInput = account.getByLabel("표시 이름");
|
||||
await expect(nameInput).toHaveValue("설정 유스케이스 학습자");
|
||||
|
||||
await nameInput.fill("");
|
||||
await expect(nameInput).toHaveValue("");
|
||||
await waitTwoFrames(page);
|
||||
|
||||
const patchPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/me"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await account.locator(".vg-set__foot .vg-btn").click();
|
||||
const patchResponse = await patchPromise;
|
||||
expect(patchResponse.status()).toBe(422);
|
||||
await waitTwoFrames(page);
|
||||
|
||||
// 거절된 저장에 저장됨 플래시가 떠서는 안 되고, 입력값은 사용자가 둔 그대로다.
|
||||
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
||||
await expect(nameInput).toHaveValue("");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 과도하게 긴 표시 이름을 저장 시도했다가 서버 거절을 받는다
|
||||
test("과도하게 긴 표시 이름 저장이 거절되면 저장됨 표시가 뜨지 않는다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page, { profilePatchStatus: 422 });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const account = page.locator("#set-account");
|
||||
const nameInput = account.getByLabel("표시 이름");
|
||||
const overlongName = "가나다라마바사아자차".repeat(20); // 200자
|
||||
await nameInput.fill(overlongName);
|
||||
await expect(nameInput).toHaveValue(overlongName);
|
||||
await waitTwoFrames(page);
|
||||
|
||||
const patchPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/me"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await account.locator(".vg-set__foot .vg-btn").click();
|
||||
const patchResponse = await patchPromise;
|
||||
expect(patchResponse.status()).toBe(422);
|
||||
// 전송 본문에는 사용자가 입력한 값이 그대로 담긴다(클라이언트 임의 절단 없음).
|
||||
const body = patchResponse.request().postDataJSON() as { display_name?: string };
|
||||
expect(body.display_name).toBe(overlongName);
|
||||
await waitTwoFrames(page);
|
||||
|
||||
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 지원 요청 섹션에서 접수한 티켓들의 상태·분류·해결 메모를 확인한다
|
||||
test("지원 요청 목록 — 접수 티켓의 상태·분류·우선순위·해결 메모가 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mountSettingsFixtures(page, {
|
||||
tickets: [
|
||||
buildTicket({
|
||||
ticket_id: "uc-ticket-resolved",
|
||||
status: "resolved",
|
||||
category: "voice_browser",
|
||||
priority: "high",
|
||||
subject: "음성 재생이 끊깁니다",
|
||||
resolution_note: "브라우저 권한 재허용으로 해결했습니다.",
|
||||
assigned_group: "서비스 운영자",
|
||||
resolved_at: TERMS_AT + 4000,
|
||||
}),
|
||||
buildTicket({
|
||||
ticket_id: "uc-ticket-open",
|
||||
status: "open",
|
||||
category: "session_review",
|
||||
priority: "normal",
|
||||
subject: "리뷰 결과가 열리지 않아요",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const support = page.locator("#set-support");
|
||||
await expect(support.getByText("2건")).toBeVisible();
|
||||
|
||||
const tickets = support.locator(".vg-set__ticket");
|
||||
await expect(tickets).toHaveCount(2);
|
||||
|
||||
const resolvedTicket = tickets.filter({ hasText: "음성 재생이 끊깁니다" });
|
||||
await expect(resolvedTicket.getByText("해결", { exact: true })).toBeVisible();
|
||||
await expect(resolvedTicket).toContainText("음성·브라우저");
|
||||
await expect(resolvedTicket).toContainText("높음");
|
||||
await expect(resolvedTicket).toContainText("해결 메모");
|
||||
await expect(resolvedTicket).toContainText("브라우저 권한 재허용으로 해결했습니다.");
|
||||
|
||||
const openTicket = tickets.filter({ hasText: "리뷰 결과가 열리지 않아요" });
|
||||
await expect(openTicket.getByText("접수", { exact: true })).toBeVisible();
|
||||
await expect(openTicket).toContainText("처리 메모가 아직 없습니다.");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 지원 요청 섹션에서 새 지원 티켓을 작성해 전송한다
|
||||
test("지원 요청 섹션에서 새 지원 티켓을 작성해 전송할 수 있다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
// 서버 계약에는 POST /users/support-tickets(UserSupportTicketRequest)가 있으나
|
||||
// 설정 화면에 작성 진입점이 있는지 검증한다. 사용자는 접수 목록을 보는 곳에서
|
||||
// 새 요청도 접수할 수 있기를 기대한다.
|
||||
const support = page.locator("#set-support");
|
||||
const composeEntry = support.getByRole("button", {
|
||||
name: /작성|접수|보내기|새 (요청|문의|티켓)/,
|
||||
});
|
||||
await expect(composeEntry).toBeVisible();
|
||||
|
||||
await composeEntry.click();
|
||||
const subjectInput = page.getByRole("textbox", { name: /제목|요약/ });
|
||||
await expect(subjectInput).toBeVisible();
|
||||
await subjectInput.fill("설정 화면에서 접수하는 문의");
|
||||
const bodyInput = page.getByRole("textbox", { name: /내용|본문|상세/ });
|
||||
await bodyInput.fill("음성 프리셋 저장이 간헐적으로 실패합니다.");
|
||||
|
||||
const postPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/support-tickets"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: /전송|접수|제출/ }).click();
|
||||
await postPromise;
|
||||
});
|
||||
|
||||
// usecase: 학습자가 음성 프리셋을 바꾸고 저장한다
|
||||
test("음성 프리셋 변경 — 다른 프리셋 선택 후 저장하면 새 프리셋이 저장 요청에 담긴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mountSettingsFixtures(page);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const voice = page.locator("#set-voice");
|
||||
const radios = voice.getByRole("radio");
|
||||
await expect(radios).toHaveCount(2);
|
||||
// 초기 선택은 서버 환경설정의 프리셋이다.
|
||||
await expect(radios.nth(0)).toHaveAttribute("aria-checked", "true");
|
||||
await expect(radios.nth(1)).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await radios.nth(1).click();
|
||||
await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true");
|
||||
await expect(radios.nth(0)).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
const patchPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/me/preferences"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await voice.getByRole("button", { name: "음성 저장" }).click();
|
||||
const patchResponse = await patchPromise;
|
||||
const body = patchResponse.request().postDataJSON() as { voice_preset_id?: string };
|
||||
expect(body.voice_preset_id).toBe(VOICE_PRESETS[1].id);
|
||||
|
||||
await expect(voice.locator(".vg-set__saved")).toContainText("저장됨");
|
||||
});
|
||||
|
||||
// usecase: 음성 저장이 실패해도 학습자의 선택 상태가 사라지지 않고 거짓 저장 표시가 없다
|
||||
test("음성 저장 실패 시 저장됨 표시가 뜨지 않고 선택 상태가 유지된다", async ({ page }) => {
|
||||
await mountSettingsFixtures(page, { preferencesPatchStatus: 500 });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const voice = page.locator("#set-voice");
|
||||
const radios = voice.getByRole("radio");
|
||||
await radios.nth(1).click();
|
||||
await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
const patchPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname.endsWith("/users/me/preferences"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await voice.getByRole("button", { name: "음성 저장" }).click();
|
||||
const patchResponse = await patchPromise;
|
||||
expect(patchResponse.status()).toBe(500);
|
||||
await waitTwoFrames(page);
|
||||
|
||||
// 실패한 저장에 저장됨 플래시가 떠서는 안 되고, 사용자의 선택은 남아 있어야 한다.
|
||||
await expect(voice.locator(".vg-set__saved")).toHaveCount(0);
|
||||
await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
});
|
||||
927
apps/web/e2e/uc-teacher-console.spec.ts
Normal file
927
apps/web/e2e/uc-teacher-console.spec.ts
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
/**
|
||||
* uc-teacher-console.spec.ts — 교수자 콘솔·수퍼비전 여정 유스케이스 스펙.
|
||||
*
|
||||
* 목적: 교수자가 (1) 콘솔 큐에서 검토 대상을 고르고, (2) 수퍼비전 attention
|
||||
* 큐에서 위험 신호 우선순위를 따라 3클릭 이내로 학습자 근거까지 내려가며,
|
||||
* (3) 학습자 상세에서 측정 근거(적절성·라포·AI 평가 상태)를 확인하고,
|
||||
* (4) 교수자 회기 리뷰에서 읽기전용 경계를 지키면서 재평가 상태 제출과
|
||||
* append-only 교정(정정 이력)을 남기는 실제 여정을 검증한다.
|
||||
*
|
||||
* 근거: apps/web/src/pages/Professor.tsx, SupervisionResearch.tsx,
|
||||
* SessionReview.tsx(+ session-review/CalibrationTransferCard.tsx)의 실제 UI 계약.
|
||||
* 모든 응답은 route fixture로 고정하며 실제 AI 엔진 턴 생성은 하지 않는다.
|
||||
*
|
||||
* 중복 회피: full-sweep-professor.spec.ts(가드·KPI·정렬 세그먼트·트렌드 차트·
|
||||
* 300행 가상화·단계 카드), teacher.spec.ts(대기 행 클릭→검토 완료 닫기),
|
||||
* supervision-research.spec.ts(교수/연구 렌즈 노출 경계), full-sweep-session-review
|
||||
* (워크시트 메모·반려), uc-session-review-core(AI 평가 재시도)는 다루지 않는다.
|
||||
*/
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
filledReviewResponse,
|
||||
emptyReviewResponse,
|
||||
} from "./session-review-fixture";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
async function routeUnmockedApi(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({ detail: "not part of this focused fixture" }, 404),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeAuthedTeacher(page: Page) {
|
||||
await page.route("**/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "62000000-0000-0000-0000-000000000902",
|
||||
email: "teacher@hs.ac.kr",
|
||||
role: "teacher",
|
||||
display_name: "E2E Teacher",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["uc-tc-cohort"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "E2E Teacher",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function dashboardFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
source: "database",
|
||||
cohort_label: "우촌 상담실습 코호트A",
|
||||
total_learners: 0,
|
||||
active_sessions: 0,
|
||||
ended_sessions: 0,
|
||||
learner_growth: [],
|
||||
safety_alerts: [],
|
||||
pending_reviews: [],
|
||||
recent_sessions: [],
|
||||
message: "검토할 실제 회기가 없습니다.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingReviewFixture(index: number, learnerId?: string) {
|
||||
const padded = String(index).padStart(2, "0");
|
||||
return {
|
||||
session_id: `uc-tc-pending-${padded}`,
|
||||
learner_id: learnerId ?? `62000000-0000-0000-0000-0000000001${padded}`,
|
||||
learner_label: `검토 학습자 ${padded}`,
|
||||
persona_code: "P1",
|
||||
persona_name: "서연",
|
||||
session_no: index,
|
||||
status: "ended",
|
||||
stage: "정리",
|
||||
turn_count: 6 + index,
|
||||
learner_turn_count: 3,
|
||||
client_turn_count: 3,
|
||||
started_at: `2026-08-1${index}T07:00:00Z`,
|
||||
ended_at: `2026-08-1${index}T07:32:00Z`,
|
||||
review_status: "pending",
|
||||
review_note: null,
|
||||
reviewed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function safetyAlertFixture(index: number) {
|
||||
return {
|
||||
id: `uc-tc-alert-${index}`,
|
||||
session_id: `uc-tc-crisis-${index}`,
|
||||
learner_id: `62000000-0000-0000-0000-0000000003${String(index).padStart(2, "0")}`,
|
||||
learner_label: `위기 학습자 ${index}`,
|
||||
persona_code: "P1",
|
||||
ko_risk_level: "높음",
|
||||
created_at: "2026-08-15T10:00:00Z",
|
||||
resource_title: "자살예방 상담전화",
|
||||
resource_number: "109",
|
||||
};
|
||||
}
|
||||
|
||||
function growthLearnerFixture(
|
||||
id: string,
|
||||
label: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
learner_id: id,
|
||||
learner_label: label,
|
||||
sessions: 2,
|
||||
ended_sessions: 2,
|
||||
latest_at: "2026-08-12T09:30:00Z",
|
||||
first_score: 0.5,
|
||||
latest_score: 0.8,
|
||||
score_delta: 0.3,
|
||||
avg_score: 0.65,
|
||||
avg_rapport: 0.3,
|
||||
trend: "up",
|
||||
top_techniques: ["reflection"],
|
||||
points: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function growthPointFixture(
|
||||
learnerId: string,
|
||||
index: number,
|
||||
score: number,
|
||||
rapport: number,
|
||||
) {
|
||||
return {
|
||||
session_id: `${learnerId}-session-${index}`,
|
||||
session_no: index,
|
||||
persona_code: "P1",
|
||||
stage: index > 1 ? "개입" : "탐색",
|
||||
started_at: `2026-08-1${index}T09:00:00Z`,
|
||||
ended_at: `2026-08-1${index}T09:30:00Z`,
|
||||
score,
|
||||
rapport,
|
||||
technique_count: index + 1,
|
||||
watch_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function analysisSessionFixture(
|
||||
learnerId: string,
|
||||
label: string,
|
||||
index: number,
|
||||
) {
|
||||
return {
|
||||
session_id: `${learnerId}-session-${index}`,
|
||||
learner_id: learnerId,
|
||||
learner_label: label,
|
||||
persona_code: "P1",
|
||||
persona_name: "서연",
|
||||
session_no: index,
|
||||
status: "ended",
|
||||
stage: index > 1 ? "개입" : "탐색",
|
||||
turn_count: 8 + index,
|
||||
learner_turn_count: 4 + index,
|
||||
client_turn_count: 4,
|
||||
started_at: `2026-08-1${index}T09:00:00Z`,
|
||||
ended_at: `2026-08-1${index}T09:30:00Z`,
|
||||
review_status: "pending",
|
||||
review_note: null,
|
||||
reviewed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function learnerAnalysisFixture(learnerId: string, label: string) {
|
||||
const points = [
|
||||
growthPointFixture(learnerId, 1, 0.5, 0.2),
|
||||
growthPointFixture(learnerId, 2, 0.8, 0.4),
|
||||
];
|
||||
const sessions = [
|
||||
analysisSessionFixture(learnerId, label, 1),
|
||||
analysisSessionFixture(learnerId, label, 2),
|
||||
];
|
||||
return {
|
||||
source: "database",
|
||||
learner_id: learnerId,
|
||||
learner_label: label,
|
||||
total_sessions: sessions.length,
|
||||
active_sessions: 0,
|
||||
ended_sessions: sessions.length,
|
||||
pending_reviews: sessions.length,
|
||||
closed_reviews: 0,
|
||||
summary: growthLearnerFixture(learnerId, label, { points }),
|
||||
points,
|
||||
stage_breakdown: [
|
||||
{ stage: "라포", sessions: 0, turns: 0 },
|
||||
{ stage: "탐색", sessions: 1, turns: 34 },
|
||||
{ stage: "개입", sessions: 1, turns: 42 },
|
||||
{ stage: "정리", sessions: 0, turns: 0 },
|
||||
],
|
||||
sessions,
|
||||
message: `${label} 전체 회기 ${sessions.length}건`,
|
||||
};
|
||||
}
|
||||
|
||||
function attentionItemFixture(
|
||||
position: number,
|
||||
learnerRef: string,
|
||||
primarySignal: string,
|
||||
drilldownRoutes: string[],
|
||||
) {
|
||||
return {
|
||||
item_id: `uc-tc-attention-${position}`,
|
||||
snapshot_id: "uc-tc-snapshot-001",
|
||||
learner_id: `62000000-0000-0000-0000-0000000005${String(position).padStart(2, "0")}`,
|
||||
learner_ref: learnerRef,
|
||||
cohort_id: "uc-tc-cohort",
|
||||
queue_position: position,
|
||||
primary_signal: primarySignal,
|
||||
oldest_active_sequence: position + 3,
|
||||
drilldown_routes: drilldownRoutes,
|
||||
evidence_pointer_ids: [`pointer-${learnerRef}-1`, `pointer-${learnerRef}-2`],
|
||||
created_at: "2026-08-15T03:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function routeSupervision(
|
||||
page: Page,
|
||||
attentionItems: Array<Record<string, unknown>>,
|
||||
) {
|
||||
await page.route("**/api/supervision-research/supervision-view", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
attention_items: attentionItems,
|
||||
curriculum_gaps: [],
|
||||
clinical_claim_allowed: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/supervision-research/research-view", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
calibration_dataset: [],
|
||||
drift_reports: [],
|
||||
phase3_manifests: [],
|
||||
raw_transcript_included: false,
|
||||
clinical_claim_allowed: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeConsole(
|
||||
page: Page,
|
||||
dashboard: Record<string, unknown> = {},
|
||||
) {
|
||||
await page.route("**/api/teacher/dashboard", (route) =>
|
||||
route.fulfill(jsonRoute(dashboardFixture(dashboard))),
|
||||
);
|
||||
await page.route("**/api/personas/review", (route) =>
|
||||
route.fulfill(jsonRoute([])),
|
||||
);
|
||||
}
|
||||
|
||||
/** 교수자 회기 리뷰 화면 응답(리뷰 본문 + 동맹 펄스 빈 목록)을 고정한다. */
|
||||
async function routeTeacherSessionReview(
|
||||
page: Page,
|
||||
sessionId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
|
||||
route.fulfill(jsonRoute({ ...filledReviewResponse(sessionId), ...overrides })),
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
const ASSESSMENT_SNAPSHOT_ID = "62000000-0000-0000-0000-00000000aa01";
|
||||
|
||||
/** 교수자 교정 패널이 읽는 캘리브레이션 read model (판정 1건 + 기존 교정 이력). */
|
||||
function calibrationReadModelFixture(
|
||||
sessionId: string,
|
||||
teacherReviews: Array<Record<string, unknown>> = [],
|
||||
) {
|
||||
const competencyId = "competency.empathic_reflection";
|
||||
return {
|
||||
learner_id: "62000000-0000-0000-0000-000000000111",
|
||||
requested_view: "supervisor",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [],
|
||||
transfer_suites: [],
|
||||
teacher_reviews: teacherReviews,
|
||||
calibration_assessments: [
|
||||
{
|
||||
assessment_snapshot_id: ASSESSMENT_SNAPSHOT_ID,
|
||||
competency_id: competencyId,
|
||||
session_id: sessionId,
|
||||
snapshot_no: 1,
|
||||
created_at: "2026-08-15T09:00:00Z",
|
||||
evidence_turn_ids: [],
|
||||
instrument_id: "vignette.calibration-assessment",
|
||||
instrument_version: "1.0.0",
|
||||
model_run_id: "62000000-0000-0000-0000-00000000bb01",
|
||||
prescription_id: "62000000-0000-0000-0000-00000000cc01",
|
||||
source_observation_ids: [],
|
||||
assessment_payload: {
|
||||
bias: "overconfident",
|
||||
competency_id: competencyId,
|
||||
counterevidence: [],
|
||||
excluded_block_ids: [],
|
||||
improvement: "insufficient_evidence",
|
||||
mean_absolute_error: 0.18,
|
||||
error_interval: { lower: 0.1, upper: 0.3 },
|
||||
pair_count: 3,
|
||||
pairs: [],
|
||||
},
|
||||
prescription_payload: {
|
||||
bias: "overconfident",
|
||||
competency_id: competencyId,
|
||||
completion_evidence: ["counterevidence_listed"],
|
||||
instruction_ko: "다음 예측 전에 실패 가능 근거를 먼저 적는다",
|
||||
practice_mode: "counterevidence_forecast",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function teacherReviewItemFixture(reviewNo: number, reason: string) {
|
||||
return {
|
||||
review_id: `62000000-0000-0000-0000-00000000dd0${reviewNo}`,
|
||||
review_no: reviewNo,
|
||||
review_reason: reason,
|
||||
disposition: "corrected",
|
||||
target_kind: "calibration_assessment",
|
||||
target_id: ASSESSMENT_SNAPSHOT_ID,
|
||||
correction_payload: {},
|
||||
counterevidence: [],
|
||||
evidence_turn_ids: [],
|
||||
created_at: `2026-08-1${reviewNo}T09:00:00Z`,
|
||||
created_by_role: "instructor",
|
||||
created_by_uid: "62000000-0000-0000-0000-000000000902",
|
||||
submission_id: `62000000-0000-0000-0000-00000000ee0${reviewNo}`,
|
||||
supersedes_review_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 빈 캘리브레이션 read model — 교수자 화면의 읽기전용 경계 확인용. */
|
||||
function emptyCalibrationReadModelFixture() {
|
||||
return {
|
||||
learner_id: "62000000-0000-0000-0000-000000000111",
|
||||
requested_view: "supervisor",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [],
|
||||
transfer_suites: [],
|
||||
teacher_reviews: [],
|
||||
calibration_assessments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 교수자 리뷰 화면에서 캘리브레이션 카드가 요구하는 read model 경로를 고정한다. */
|
||||
async function routeSupervisorCalibration(
|
||||
page: Page,
|
||||
readModel: Record<string, unknown>,
|
||||
) {
|
||||
await page.route("**/api/calibration/learners/*", (route) =>
|
||||
route.fulfill(jsonRoute(readModel)),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("uc-teacher-console: 교수 콘솔 큐", () => {
|
||||
// usecase: 교수자가 아무 기록도 없는 콘솔에 들어와 네 큐 모두의 빈 상태 안내를 읽는다
|
||||
test("빈 콘솔은 검토·위기·페르소나·최근 큐마다 빈 상태 안내를 보여준다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page);
|
||||
|
||||
await page.goto("/teach");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "검토할 실제 회기가 없습니다." }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("종료된 회기를 검토 완료로 닫으면 이 목록에서 사라집니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("현재 위기 알림 없음")).toBeVisible();
|
||||
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
|
||||
await expect(page.getByText("세션 로그 없음")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "첫 검토 열기" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 교수자가 대기 리뷰 수를 확인하고 "첫 검토 열기"로 첫 대기 회기의 검토 화면을 연다
|
||||
test("첫 검토 열기 버튼이 첫 번째 대기 회기의 교수자 검토 화면을 연다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionId = "uc-tc-pending-01";
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page, {
|
||||
ended_sessions: 2,
|
||||
pending_reviews: [pendingReviewFixture(1), pendingReviewFixture(2)],
|
||||
message: "2건의 리뷰가 대기 중입니다.",
|
||||
});
|
||||
await routeTeacherSessionReview(page, sessionId);
|
||||
|
||||
await page.goto("/teach");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "2건의 리뷰가 대기 중입니다." }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "첫 검토 열기" }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/teach/session/${sessionId}/review$`),
|
||||
);
|
||||
await expect(
|
||||
page.getByText("교수자 검토 화면", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 109 안전 확인 큐에서 위기 학습자·위험도·안내 번호를 확인한다
|
||||
test("109 안전 확인 큐가 위기 학습자와 위험도, 109 리소스를 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page, {
|
||||
safety_alerts: [safetyAlertFixture(1), safetyAlertFixture(2)],
|
||||
});
|
||||
|
||||
await page.goto("/teach");
|
||||
|
||||
const safetySection = page.locator(".pf-section--safety");
|
||||
await expect(
|
||||
safetySection.getByRole("heading", { name: "109 안전 확인 큐" }),
|
||||
).toBeVisible();
|
||||
await expect(safetySection.getByText("2건", { exact: true })).toBeVisible();
|
||||
const firstAlert = safetySection.locator(".pf-alert").first();
|
||||
await expect(firstAlert).toContainText("위기 학습자 1");
|
||||
await expect(firstAlert).toContainText("위험도 높음");
|
||||
await expect(firstAlert).toContainText("자살예방 상담전화");
|
||||
await expect(firstAlert).toContainText("109");
|
||||
await expect(firstAlert).toContainText("uc-tc-crisis-1");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 검토 큐 요약에서 담당 코호트 맥락과 대기·누적 수치를 확인한다
|
||||
test("검토 큐 요약이 코호트 라벨 툴팁과 리뷰 대기·누적 회기 수를 보여준다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page, {
|
||||
active_sessions: 1,
|
||||
ended_sessions: 3,
|
||||
pending_reviews: [pendingReviewFixture(1), pendingReviewFixture(2)],
|
||||
message: "2건의 리뷰가 대기 중입니다.",
|
||||
});
|
||||
|
||||
await page.goto("/teach");
|
||||
|
||||
const triage = page.locator(".pf-triage");
|
||||
await expect(triage).toBeVisible();
|
||||
await expect(
|
||||
triage.locator('span[title="우촌 상담실습 코호트A"]'),
|
||||
).toContainText("실제 기록");
|
||||
const meta = triage.locator(".pf-triage__meta");
|
||||
await expect(meta).toContainText("리뷰 대기");
|
||||
await expect(meta.locator("span").filter({ hasText: "리뷰 대기" }).locator("b")).toHaveText("2");
|
||||
await expect(meta.locator("span").filter({ hasText: "누적 회기" }).locator("b")).toHaveText("4");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("uc-teacher-console: 수퍼비전 attention 큐", () => {
|
||||
// usecase: 교수자가 attention 큐에서 위험 신호가 우선순위 순서로 정렬된 것을 확인한다
|
||||
test("attention 큐는 응답 순서와 무관하게 queue_position 순으로 위험 신호를 정렬한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
// 의도적으로 뒤섞인 순서(3→1→2)로 내려도 화면은 우선순위 순으로 보여야 한다.
|
||||
await routeSupervision(page, [
|
||||
attentionItemFixture(3, "learner-stagnation", "growth_stagnation", [
|
||||
"/teach/analysis?learner=uc-learner-stagnation",
|
||||
]),
|
||||
attentionItemFixture(1, "learner-deterioration", "deterioration", [
|
||||
"/teach/analysis?learner=uc-learner-deterioration",
|
||||
]),
|
||||
attentionItemFixture(2, "learner-safety", "safety_boundary", [
|
||||
"/teach/analysis?learner=uc-learner-safety",
|
||||
]),
|
||||
]);
|
||||
|
||||
await page.goto("/teach/supervision");
|
||||
|
||||
const queueNames = page.locator(".g6-queue__item .g6-queue__copy strong");
|
||||
await expect(queueNames).toHaveText([
|
||||
"learner-deterioration",
|
||||
"learner-safety",
|
||||
"learner-stagnation",
|
||||
]);
|
||||
// 위험(악화+안전 경계) 2명, 정체 1명, 미해결 관계 사건 0명 집계.
|
||||
await expect(page.getByLabel("위험 2명")).toBeVisible();
|
||||
await expect(page.getByLabel("정체 1명")).toBeVisible();
|
||||
await expect(page.getByLabel("미해결 관계 사건 0명")).toBeVisible();
|
||||
// 최우선 항목이 자동 선택되어 근거 원장이 먼저 열린다.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /learner-deterioration/ }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 attention 큐 항목을 고르고 드릴다운 링크로 3클릭 이내에 학습자 상세에 도달한다
|
||||
test("attention 항목에서 두 번의 클릭으로 학습자 상세 분석까지 내려간다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const learnerId = "uc-risk-learner";
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeSupervision(page, [
|
||||
attentionItemFixture(1, "learner-calm", "growth_stagnation", [
|
||||
"/teach/analysis?learner=uc-calm-learner",
|
||||
]),
|
||||
attentionItemFixture(2, "learner-risk", "deterioration", [
|
||||
`/teach/analysis?learner=${learnerId}`,
|
||||
]),
|
||||
]);
|
||||
await routeConsole(page, {
|
||||
total_learners: 1,
|
||||
learner_growth: [growthLearnerFixture(learnerId, "위험 학습자")],
|
||||
});
|
||||
await page.route("**/api/teacher/learners/*/analysis", (route) =>
|
||||
route.fulfill(jsonRoute(learnerAnalysisFixture(learnerId, "위험 학습자"))),
|
||||
);
|
||||
|
||||
await page.goto("/teach/supervision");
|
||||
|
||||
// 클릭 1: 큐에서 위험 학습자 항목 선택.
|
||||
await page.getByRole("button", { name: /learner-risk/ }).click();
|
||||
const drilldown = page.locator(".g6-drilldown a");
|
||||
await expect(drilldown).toHaveCount(1);
|
||||
// 클릭 2: 원장 근거 드릴다운 링크로 학습자 상세 진입 (계약 상한 3클릭 이내).
|
||||
await drilldown.first().click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerId}`));
|
||||
await expect(
|
||||
page.locator('[data-learner-analysis-panel="true"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".pf-depth-strip .is-current"),
|
||||
).toContainText("학습자 상세");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("uc-teacher-console: 학습자 상세 측정 근거", () => {
|
||||
// usecase: 교수자가 학습자 상세의 페르소나별 회기를 펼쳐 회기별 측정 근거(적절성·라포·평가 상태)를 확인한다
|
||||
test("페르소나별 회기를 펼치면 회기별 적절성·라포·평가 상태 근거가 표시된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const learnerId = "uc-learner-evidence";
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page, {
|
||||
total_learners: 1,
|
||||
learner_growth: [growthLearnerFixture(learnerId, "근거 학습자")],
|
||||
});
|
||||
await page.route("**/api/teacher/learners/*/analysis", (route) =>
|
||||
route.fulfill(jsonRoute(learnerAnalysisFixture(learnerId, "근거 학습자"))),
|
||||
);
|
||||
|
||||
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
||||
|
||||
const panel = page.locator('[data-learner-analysis-panel="true"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(
|
||||
panel.getByRole("tab", { name: /페르소나별 회기/ }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const group = panel.locator('[data-learner-persona-group="true"]');
|
||||
await expect(group).toHaveCount(1);
|
||||
// 그룹 요약에도 평균 적절성(65%) 등 집계 근거가 노출된다.
|
||||
await expect(group).toContainText("65%");
|
||||
|
||||
await group
|
||||
.getByRole("button", { name: "P1 서연 회기 펼치기" })
|
||||
.click();
|
||||
const sessions = panel.locator('[data-learner-session-row="true"]');
|
||||
await expect(sessions).toHaveCount(2);
|
||||
// 1회기: score 0.5 → 50%, rapport 0.2 → 60%, 평가·검토 상태 배지.
|
||||
await expect(sessions.nth(0)).toContainText("50%");
|
||||
await expect(sessions.nth(0)).toContainText("60%");
|
||||
await expect(sessions.nth(0)).toContainText("기록 대기");
|
||||
await expect(sessions.nth(0)).toContainText("검토 대기");
|
||||
// 2회기: score 0.8 → 80%, rapport 0.4 → 70%.
|
||||
await expect(sessions.nth(1)).toContainText("80%");
|
||||
await expect(sessions.nth(1)).toContainText("70%");
|
||||
|
||||
await group
|
||||
.getByRole("button", { name: "P1 서연 회기 접기" })
|
||||
.click();
|
||||
await expect(
|
||||
panel.locator('[data-learner-persona-sessions="true"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 교수자가 학습자 테이블을 기법 태그로 검색해 코호트 안에서 대상 학습자만 남긴다
|
||||
test("학습자 테이블 검색이 기법 태그로 행을 좁히고 지우면 전체가 복원된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeConsole(page, {
|
||||
total_learners: 2,
|
||||
learner_growth: [
|
||||
growthLearnerFixture("uc-learner-a", "반영 학습자", {
|
||||
top_techniques: ["reflection"],
|
||||
}),
|
||||
growthLearnerFixture("uc-learner-b", "질문 학습자", {
|
||||
top_techniques: ["socratic questioning"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/teach/analysis");
|
||||
|
||||
const rows = page.locator('[data-learner-overview-row="true"]');
|
||||
await expect(rows).toHaveCount(2);
|
||||
|
||||
await page.getByLabel("학습자 검색").fill("socratic");
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toContainText("질문 학습자");
|
||||
|
||||
await page.getByLabel("학습자 검색").fill("");
|
||||
await expect(rows).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("uc-teacher-console: 교수자 회기 리뷰", () => {
|
||||
const sessionId = "uc-tc-review-01";
|
||||
|
||||
async function routeReviewConsoleContext(page: Page) {
|
||||
// 캘리브레이션 카드의 교수자 조회는 dashboard에서 session_id→learner_id를
|
||||
// 찾으므로, 대기 목록의 session_id가 리뷰 화면의 세션과 일치해야 한다.
|
||||
await routeConsole(page, {
|
||||
ended_sessions: 1,
|
||||
pending_reviews: [
|
||||
{
|
||||
...pendingReviewFixture(1, "62000000-0000-0000-0000-000000000111"),
|
||||
session_id: sessionId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// usecase: 교수자가 리뷰 탭을 키보드(화살표·Home·End)만으로 순회한다
|
||||
test("리뷰 탭이 ArrowRight·End·Home 키보드 조작으로 전환된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
await routeTeacherSessionReview(page, sessionId);
|
||||
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
|
||||
const transcriptTab = page.getByRole("tab", { name: "축어록" });
|
||||
const insightsTab = page.getByRole("tab", { name: "피드백" });
|
||||
const worksheetTab = page.getByRole("tab", { name: "워크시트" });
|
||||
await expect(transcriptTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await transcriptTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(insightsTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(insightsTab).toBeFocused();
|
||||
|
||||
await page.keyboard.press("End");
|
||||
await expect(worksheetTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator("#sr-panel-worksheet")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Home");
|
||||
await expect(transcriptTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator("#sr-panel-transcript")).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 검토 화면에서 학습자 전용 조작 없이 읽기와 교정 경계만 확인한다
|
||||
test("교수자 검토 화면은 공유·자기예측 등 학습자 전용 조작을 노출하지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
await routeTeacherSessionReview(page, sessionId);
|
||||
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
|
||||
await expect(
|
||||
page.getByText("교수자 검토 화면", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "교수 콘솔로" })).toBeVisible();
|
||||
// 학습자 전용 공유 조작은 교수자 화면에 없다.
|
||||
await expect(page.getByRole("button", { name: /공유 URL/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
// 캘리브레이션 카드: 교수자는 학습자 예측을 대신 만들 수 없다.
|
||||
await expect(
|
||||
page.getByText("학습자가 잠근 예측이 아직 없어"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("첫 자기예측 기록")).toHaveCount(0);
|
||||
await expect(page.getByText("자기예측 revision 추가")).toHaveCount(0);
|
||||
// 교수자 권한은 읽기와 append-only 교정으로 명시된다.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "읽기와 교정만 가능" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("교정할 역량별 판정이 아직 없어."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 검토 메모를 적고 메모 저장으로 검토 중(viewed) 상태를 제출한다
|
||||
test("검토 메모 저장이 viewed 상태를 PUT하고 상태 라벨이 검토 중으로 바뀐다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
await routeTeacherSessionReview(page, sessionId, {
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
worksheetNote: "",
|
||||
worksheetStatus: "pending",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
});
|
||||
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
|
||||
|
||||
const statusPuts: Array<Record<string, unknown>> = [];
|
||||
await page.route(
|
||||
`**/api/teacher/sessions/${sessionId}/review-status`,
|
||||
(route: Route) => {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
statusPuts.push(body);
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: sessionId,
|
||||
status: body.status,
|
||||
note: body.note,
|
||||
reviewer_id: "62000000-0000-0000-0000-000000000902",
|
||||
reviewed_at: null,
|
||||
updated_at: "2026-08-15T10:00:00Z",
|
||||
worksheet_note: body.worksheet_note ?? "",
|
||||
worksheet_status: "pending",
|
||||
worksheet_reviewed_at: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
|
||||
const teacherCard = page.locator(".sr-card--teacher-review");
|
||||
await expect(teacherCard.locator("b").first()).toHaveText("검토 대기");
|
||||
await page
|
||||
.getByLabel("검토 메모")
|
||||
.fill("다음 회기에서 비교 장면을 더 좁혀 확인");
|
||||
await page.getByRole("button", { name: "메모 저장" }).click();
|
||||
|
||||
await expect(teacherCard.locator("b").first()).toHaveText("검토 중");
|
||||
expect(statusPuts).toHaveLength(1);
|
||||
expect(statusPuts[0].status).toBe("viewed");
|
||||
expect(statusPuts[0].note).toBe("다음 회기에서 비교 장면을 더 좁혀 확인");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 평가 미준비 회기에서 검토 완료·워크시트 판정이 잠긴 것을 확인한다
|
||||
test("평가 미준비 회기는 검토 완료와 워크시트 판정을 비활성화한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
// 종료됐지만 축어록·평가가 없는 회기: reviewReady=false, 워크시트 학습자 저장본 없음.
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
|
||||
route.fulfill(jsonRoute(emptyReviewResponse(sessionId))),
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
||||
route.fulfill(jsonRoute({ items: [] })),
|
||||
);
|
||||
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("종료 회기만 검토 완료로 닫을 수 있습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "메모 저장" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
||||
// 학습자 저장본이 없으면 워크시트 판정 세 버튼 모두 잠긴다.
|
||||
await expect(page.getByRole("button", { name: "승인" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "수정요청" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "반려" })).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 교수자가 원판정을 덮어쓰지 않는 교정 revision을 제출하고 append-only 이력을 확인한다
|
||||
test("교수자 교정이 append-only로 제출되고 기존 정정 이력이 revision 목록으로 보인다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
await routeTeacherSessionReview(page, sessionId);
|
||||
await routeSupervisorCalibration(
|
||||
page,
|
||||
calibrationReadModelFixture(sessionId, [
|
||||
teacherReviewItemFixture(1, "첫 교정: 반영 판정 근거 보강"),
|
||||
teacherReviewItemFixture(2, "둘째 교정: 과신 판정 재검토"),
|
||||
]),
|
||||
);
|
||||
|
||||
const correctionPosts: Array<Record<string, unknown>> = [];
|
||||
await page.route("**/api/calibration/reviews", (route: Route) => {
|
||||
correctionPosts.push(
|
||||
route.request().postDataJSON() as Record<string, unknown>,
|
||||
);
|
||||
return route.fulfill(jsonRoute({ recorded: true }));
|
||||
});
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
|
||||
// 판정 스냅샷(과신 경향)이 교정 대상으로 표시된다.
|
||||
await expect(page.getByText("과신 경향", { exact: true })).toBeVisible();
|
||||
const correction = page.locator(".ct-correction");
|
||||
await expect(
|
||||
correction.getByRole("heading", { name: "읽기와 교정만 가능" }),
|
||||
).toBeVisible();
|
||||
await expect(correction.locator("select")).toHaveValue(
|
||||
`calibration_assessment:${ASSESSMENT_SNAPSHOT_ID}`,
|
||||
);
|
||||
|
||||
// 기존 append-only 정정 이력 2건이 revision 번호와 함께 노출된다.
|
||||
const history = correction.locator(".ct-review-history");
|
||||
await expect(history.locator("summary")).toHaveText(
|
||||
"기존 교수자 교정 2개 보기",
|
||||
);
|
||||
await history.locator("summary").click();
|
||||
await expect(history.locator("li")).toHaveCount(2);
|
||||
await expect(history.locator("li").nth(0)).toContainText(
|
||||
"revision 1 · 첫 교정: 반영 판정 근거 보강",
|
||||
);
|
||||
await expect(history.locator("li").nth(1)).toContainText(
|
||||
"revision 2 · 둘째 교정: 과신 판정 재검토",
|
||||
);
|
||||
|
||||
await page
|
||||
.getByLabel("교정 이유 (발화 원문 제외)")
|
||||
.fill("반영 판정에서 침묵 허용 맥락을 반영해 다시 읽어야 함");
|
||||
await correction
|
||||
.getByRole("button", { name: "교정 revision 추가" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText("교수자 교정을 append-only 원장에 추가했어."),
|
||||
).toBeVisible();
|
||||
expect(correctionPosts).toHaveLength(1);
|
||||
expect(correctionPosts[0].disposition).toBe("corrected");
|
||||
expect(correctionPosts[0].target_kind).toBe("calibration_assessment");
|
||||
expect(correctionPosts[0].target_id).toBe(ASSESSMENT_SNAPSHOT_ID);
|
||||
expect(
|
||||
(correctionPosts[0].correction_payload as { teacher_note: string })
|
||||
.teacher_note,
|
||||
).toBe("반영 판정에서 침묵 허용 맥락을 반영해 다시 읽어야 함");
|
||||
});
|
||||
|
||||
// usecase: 교수자가 이미 검토 완료로 닫힌 회기를 다시 열면 저장된 완료 이력이 그대로 보인다
|
||||
test("검토 완료된 회기는 완료 시각·메모를 보존하고 재완료를 막는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeAuthedTeacher(page);
|
||||
await routeReviewConsoleContext(page);
|
||||
await routeTeacherSessionReview(page, sessionId, {
|
||||
teacherReview: {
|
||||
status: "closed",
|
||||
note: "감정 반영 흐름 양호, 다음은 준비도 확인",
|
||||
worksheetNote: "",
|
||||
worksheetStatus: "pending",
|
||||
reviewedAt: "2026-08-10T09:00:00Z",
|
||||
reviewerId: "62000000-0000-0000-0000-000000000902",
|
||||
updatedAt: "2026-08-10T09:00:00Z",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
});
|
||||
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
|
||||
const teacherCard = page.locator(".sr-card--teacher-review");
|
||||
await expect(teacherCard.locator("b").first()).toHaveText("검토 완료");
|
||||
await expect(
|
||||
page.getByText("완료 시각 2026-08-10T09:00:00Z"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("검토 메모")).toHaveValue(
|
||||
"감정 반영 흐름 양호, 다음은 준비도 확인",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -240,6 +240,12 @@
|
|||
color: var(--text-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* 포커스 가시성 — 보태니컬 is-active 규칙이 box-shadow 를 점유해 전역
|
||||
:focus-visible 링이 사라지므로 outline 채널로 링을 보장한다(WCAG 2.4.7). */
|
||||
.vg-nav__item:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.vg-nav__item .vg-nav__ic {
|
||||
flex: none;
|
||||
color: var(--text-muted);
|
||||
|
|
|
|||
|
|
@ -148,6 +148,12 @@
|
|||
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-border));
|
||||
background: var(--glass-specular), var(--glass-surface-strong);
|
||||
}
|
||||
/* 포커스 가시성 — 자체 box-shadow(글래스 인셋)가 전역 :focus-visible 링(box-shadow)을
|
||||
덮으므로, 아무도 쓰지 않는 outline 채널로 링을 그린다(WCAG 2.4.7). */
|
||||
.vg-surface.vg-surface--interactive:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.vg-surface.vg-surface--flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -682,6 +682,8 @@ export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
|
|||
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
|
||||
export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">;
|
||||
export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">;
|
||||
export type UserSupportTicketCreateRequest = ApiSchema<"UserSupportTicketRequest">;
|
||||
export type UserSupportTicketCreateResponse = ApiSchema<"UserSupportTicketResponse">;
|
||||
export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">;
|
||||
export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">;
|
||||
export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">;
|
||||
|
|
@ -712,6 +714,8 @@ export const userApi = {
|
|||
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
||||
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
||||
supportTickets: () => api.get<UserSupportTicketsResponse>("/users/support-tickets"),
|
||||
createSupportTicket: (body: UserSupportTicketCreateRequest) =>
|
||||
api.post<UserSupportTicketCreateResponse>("/users/support-tickets", body),
|
||||
prepostMeasures: (pilotId = "phase3-pilot-draft") =>
|
||||
api.get<UserPrepostMeasuresResponse>(
|
||||
`/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`,
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ interface TicketActionsProps {
|
|||
onUpdateStatus: (
|
||||
ticket: AdminSupportTicket,
|
||||
status: AdminSupportTicket["status"],
|
||||
resolutionNote?: string,
|
||||
) => Promise<void>;
|
||||
onUpdateParent: (
|
||||
ticket: AdminSupportTicket,
|
||||
|
|
@ -421,6 +422,8 @@ function TicketActions({
|
|||
onUpdateParent,
|
||||
}: TicketActionsProps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
// 해결 노트 — 학습자 설정 화면(resolution_note)에 그대로 보이는 처리 메모.
|
||||
const [resolutionNote, setResolutionNote] = useState("");
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
const run = (action: () => Promise<void>) => {
|
||||
|
|
@ -438,6 +441,16 @@ function TicketActions({
|
|||
|
||||
return (
|
||||
<div className="vgops-ticket__actions" aria-busy={pending}>
|
||||
<textarea
|
||||
className="vgops-ticket__note-input"
|
||||
rows={2}
|
||||
value={resolutionNote}
|
||||
aria-label="해결 노트"
|
||||
placeholder="해결 시 학습자에게 보일 처리 메모"
|
||||
maxLength={2000}
|
||||
disabled={pending}
|
||||
onChange={(event) => setResolutionNote(event.target.value)}
|
||||
/>
|
||||
{canLinkDuplicate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -469,7 +482,11 @@ function TicketActions({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => run(() => onUpdateStatus(ticket, "resolved"))}
|
||||
onClick={() =>
|
||||
run(() =>
|
||||
onUpdateStatus(ticket, "resolved", resolutionNote.trim() || undefined),
|
||||
)
|
||||
}
|
||||
disabled={pending}
|
||||
>
|
||||
해결
|
||||
|
|
@ -1073,9 +1090,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const updateTicketStatus = async (
|
||||
ticket: AdminSupportTicket,
|
||||
status: AdminSupportTicket["status"],
|
||||
resolutionNote?: string,
|
||||
) => {
|
||||
try {
|
||||
const updated = await adminApi.updateTicket(ticket.ticket_id, { status });
|
||||
const updated = await adminApi.updateTicket(ticket.ticket_id, {
|
||||
status,
|
||||
...(resolutionNote ? { resolution_note: resolutionNote } : {}),
|
||||
});
|
||||
setTicketsError(null);
|
||||
setTickets((current) =>
|
||||
current
|
||||
|
|
|
|||
|
|
@ -1260,7 +1260,8 @@ export default function SessionReview() {
|
|||
{data.client.initial}
|
||||
</span>
|
||||
<div>
|
||||
<div className="sr-head__name">{data.client.name}</div>
|
||||
{/* 페이지 유일 h1 — 스크린리더 헤딩 구조의 시작점(WCAG 1.3.1/2.4.6) */}
|
||||
<h1 className="sr-head__name">{data.client.name}</h1>
|
||||
{/* D3: 날짜·소요시간만 가운뎃점 1개로 묶고, 페르소나 메타는 아래 줄 칩으로 분리 */}
|
||||
<div className="sr-head__meta">
|
||||
<span className="sr-mono">{data.date}</span>
|
||||
|
|
|
|||
|
|
@ -235,6 +235,12 @@ export default function Settings() {
|
|||
const [preferencesReady, setPreferencesReady] = useState(false);
|
||||
const [supportTicketsReady, setSupportTicketsReady] = useState(false);
|
||||
const [supportTicketsError, setSupportTicketsError] = useState<string | null>(null);
|
||||
// 지원 요청 작성 — 접수 목록을 보는 자리에서 새 티켓을 접수한다(POST /users/support-tickets).
|
||||
const [supportComposeOpen, setSupportComposeOpen] = useState(false);
|
||||
const [supportSubject, setSupportSubject] = useState("");
|
||||
const [supportBody, setSupportBody] = useState("");
|
||||
const [supportSubmitting, setSupportSubmitting] = useState(false);
|
||||
const [supportComposeError, setSupportComposeError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||
|
|
@ -264,6 +270,39 @@ export default function Settings() {
|
|||
savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400);
|
||||
};
|
||||
|
||||
const submitSupportTicket = async () => {
|
||||
const subject = supportSubject.trim();
|
||||
const body = supportBody.trim();
|
||||
if (!subject || !body || supportSubmitting) return;
|
||||
setSupportSubmitting(true);
|
||||
setSupportComposeError(null);
|
||||
try {
|
||||
await userApi.createSupportTicket({
|
||||
subject,
|
||||
body,
|
||||
category: "other",
|
||||
priority: "normal",
|
||||
source_path: "/settings",
|
||||
});
|
||||
// 접수 직후 목록을 재조회해 서버 원장의 상태를 그대로 보여준다.
|
||||
const nextTickets = await userApi.supportTickets().catch(() => null);
|
||||
if (nextTickets) {
|
||||
setSupportTickets(nextTickets);
|
||||
setSupportTicketsError(null);
|
||||
}
|
||||
setSupportComposeOpen(false);
|
||||
setSupportSubject("");
|
||||
setSupportBody("");
|
||||
flashSaved("support");
|
||||
} catch (err) {
|
||||
setSupportComposeError(
|
||||
err instanceof Error ? err.message : "지원 요청을 접수하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setSupportSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEngineConfig = useCallback((patch: Partial<AdminEngineConfigResponse>) => {
|
||||
if (!engineConfigRef.current) return;
|
||||
engineConfigRef.current = { ...engineConfigRef.current, ...patch };
|
||||
|
|
@ -841,13 +880,75 @@ export default function Settings() {
|
|||
내가 접수한 운영 티켓의 처리 상태와 해결 메모입니다.
|
||||
</div>
|
||||
</div>
|
||||
{supportTickets ? (
|
||||
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
|
||||
{supportTickets.tickets.length}건
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="vg-set__support-head-ops">
|
||||
{supportTickets ? (
|
||||
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
|
||||
{supportTickets.tickets.length}건
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-expanded={supportComposeOpen}
|
||||
onClick={() => setSupportComposeOpen((open) => !open)}
|
||||
>
|
||||
새 요청 작성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportComposeOpen ? (
|
||||
<div className={surfaceClassName("vg-set__support-compose", { variant: "inset", flat: true })}>
|
||||
<Field>
|
||||
<Input
|
||||
value={supportSubject}
|
||||
aria-label="요청 제목"
|
||||
placeholder="어떤 문제인지 한 줄로 요약해 주세요"
|
||||
maxLength={200}
|
||||
disabled={supportSubmitting}
|
||||
onChange={(event) => setSupportSubject(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<textarea
|
||||
className="vg-input vg-set__support-textarea"
|
||||
rows={4}
|
||||
value={supportBody}
|
||||
aria-label="요청 내용"
|
||||
placeholder="언제, 어떤 화면에서, 무엇이 발생했는지 적어 주세요."
|
||||
maxLength={4000}
|
||||
disabled={supportSubmitting}
|
||||
onChange={(event) => setSupportBody(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{supportComposeError ? (
|
||||
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
<span>{supportComposeError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="vg-set__support-compose-foot">
|
||||
<span>접수된 요청은 아래 목록에서 처리 상태를 확인할 수 있습니다.</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
supportSubmitting || !supportSubject.trim() || !supportBody.trim()
|
||||
}
|
||||
onClick={() => void submitSupportTicket()}
|
||||
>
|
||||
{supportSubmitting ? "접수 중" : "요청 전송"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{savedKey === "support" ? (
|
||||
<div className="vg-set__state" role="status">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
<span>지원 요청을 접수했습니다.</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{supportTicketsError ? (
|
||||
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -1457,6 +1457,26 @@
|
|||
max-width: 190px;
|
||||
}
|
||||
|
||||
/* 해결 노트 — 해결 처리와 함께 학습자에게 보일 처리 메모를 남긴다. */
|
||||
.vgops-ticket__note-input {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-body);
|
||||
padding: 6px 9px;
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vgops-ticket__note-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vgops-ticket__actions .vg-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@
|
|||
}
|
||||
.sr-actions .vg-btn {
|
||||
width: 100%;
|
||||
min-height: 44px; /* 터치 타깃 44×44 — 전 폭 공통(WCAG 2.5.5) */
|
||||
}
|
||||
.sr-share-note {
|
||||
min-width: 0;
|
||||
|
|
@ -845,6 +846,8 @@
|
|||
}
|
||||
.sr-chip-toggle {
|
||||
flex: none;
|
||||
/* 데스크톱은 밀도 계약(≤34px, session-review.spec 폭 분기)을 지킨다 —
|
||||
터치 44px은 아래 좁은 폭 미디어쿼리가 소유한다. */
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@
|
|||
min-width: 0;
|
||||
}
|
||||
.sx-sessionbar button {
|
||||
min-height: 32px;
|
||||
min-height: 44px; /* 터치 타깃 44×44 (WCAG 2.5.5 / 플랫폼 HIG) */
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -5113,7 +5113,7 @@
|
|||
box-shadow: var(--glass-edge-shadow), var(--glass-shadow);
|
||||
}
|
||||
.sx-page--active .sx-sessionbar button {
|
||||
min-height: 42px;
|
||||
min-height: 44px; /* 데스크톱에서도 44px 터치 계약 유지(WCAG 2.5.5) */
|
||||
padding: 0 16px;
|
||||
border-color: rgba(159, 142, 111, 0.23);
|
||||
border-radius: 14px;
|
||||
|
|
@ -5495,7 +5495,9 @@
|
|||
min-width: 134px;
|
||||
min-height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #3d947d, #267762);
|
||||
/* 하드코딩 청록 그라디언트(#3d947d→#267762)는 다크 테마의
|
||||
--text-on-accent(#0f1a17)와 3.3:1로 4.5:1 미달 — 테마 토큰으로 교체. */
|
||||
background: linear-gradient(145deg, var(--accent), var(--accent-deep));
|
||||
box-shadow: 0 8px 18px rgba(43, 119, 97, 0.2);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,34 @@ body[data-page="settings"] .vg-set__ticket-note b {
|
|||
body[data-page="settings"] .vg-set__ticket-note--empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* 지원 요청 작성 — 목록 위에서 새 티켓을 접수하는 인라인 폼. */
|
||||
body[data-page="settings"] .vg-set__support-head-ops {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__support-compose {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: var(--sp-4);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__support-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__support-compose-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__support-compose-foot > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__foot {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue