- 지원 티켓 작성 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)
655 lines
24 KiB
TypeScript
655 lines
24 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];
|
|
|
|
// 평가가 아직 없는 학습자 — 리뷰 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");
|
|
});
|
|
});
|