vignette/apps/web/e2e/uc-learner-home-dashboard.spec.ts

808 lines
29 KiB
TypeScript

// 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];
const TRAINING_EXPOSURE_CONTRACT = {
version: "training-exposure-dominant-share.v1",
minimum_completed_sessions: 4,
attention_threshold: 0.75,
definition:
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.",
};
// 평가가 아직 없는 학습자 — 리뷰 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: [],
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "insufficient",
label: "판정 근거 부족",
completed_sessions: 1,
dominant_persona_code: "P2",
dominant_persona_name: "김도윤",
dominant_sessions: 1,
dominant_share: 1,
},
};
// 라포 게이지 검증용 — 서로 다른 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: [],
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "insufficient",
label: "판정 근거 부족",
completed_sessions: 0,
dominant_persona_code: null,
dominant_persona_name: null,
dominant_sessions: 0,
dominant_share: null,
},
};
// 스포트라이트(진행 중 회기) 상세 — 리캡 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,
},
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "balanced",
label: "균형",
completed_sessions: 50,
dominant_persona_code: "P1",
dominant_persona_name: "성하늘",
dominant_sessions: 25,
dominant_share: 0.5,
},
};
const DASH_ATTENTION = {
...DASH_NO_EVAL,
overview: {
...DASH_NO_EVAL.overview,
total_sessions: 4,
active_sessions: 0,
completed_sessions: 4,
},
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "attention",
label: "훈련 집중 주의",
completed_sessions: 4,
dominant_persona_code: "P1",
dominant_persona_name: "성하늘",
dominant_sessions: 3,
dominant_share: 0.75,
},
};
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회");
});
test("종료 회기 0회는 훈련 노출 판정 근거 부족으로 표시한다", async ({ page }) => {
await installFixtures(page, {
sessions: [],
dashboard: EMPTY_DASHBOARD,
detail: null,
});
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("판정 근거 부족");
await expect(exposure).toContainText("종료 회기 0/4회");
await expect(exposure).toContainText(
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
);
});
test("종료 회기 1회는 비중과 무관하게 훈련 노출 판정을 보류한다", async ({ page }) => {
await installFixtures(page);
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("판정 근거 부족");
await expect(exposure).toContainText("종료 회기 1/4회");
});
test("종료 회기 50회의 동률 노출은 균형으로 표시한다", async ({ page }) => {
await installFixtures(page, {
sessions: BULK_SESSIONS,
dashboard: DASH_BULK,
detail: null,
});
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("균형");
await expect(exposure).toContainText("성하늘 25/50회 · 최다 노출 50%");
});
test("종료 회기 4회 중 3회 노출은 훈련 집중 주의로 표시한다", async ({ page }) => {
await installFixtures(page, { dashboard: DASH_ATTENTION });
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("훈련 집중 주의");
await expect(exposure).toContainText("성하늘 3/4회 · 최다 노출 75%");
await expect(exposure).toContainText(
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
);
});
// 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$/);
});
test("성장 지표 라포 헤더는 중첩 inset 표면을 사용하지 않는다", async ({
page,
}) => {
await installFixtures(page);
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const rapportHeading = page
.locator("#lh-panel-growth")
.getByRole("heading", { name: "회기를 건너 누적됩니다." });
const headingContent = rapportHeading.locator("..");
await expect(rapportHeading).toBeVisible();
await expect(headingContent).not.toHaveClass(/vg-surface/);
await expect
.poll(() =>
headingContent.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius,
padding: style.padding,
};
}),
)
.toEqual({
backgroundColor: "rgba(0, 0, 0, 0)",
borderTopWidth: "0px",
borderRadius: "0px",
padding: "0px",
});
});
// 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");
});
});