811 lines
29 KiB
TypeScript
811 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");
|
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
|
|
|
const cards = page.locator("#lh-panel-growth .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.getByRole("tab", { name: "성장 지표" }).click();
|
|
|
|
await page
|
|
.locator("#lh-panel-growth .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\?continuity=fresh$/);
|
|
});
|
|
|
|
// 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 page.getByRole("tab", { name: "성장 지표" }).click();
|
|
await expect(
|
|
page.locator("#lh-panel-growth .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\?continuity=fresh$/);
|
|
});
|
|
|
|
// 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");
|
|
});
|
|
});
|