vignette/apps/web/e2e/full-sweep-learner-home.spec.ts

1219 lines
44 KiB
TypeScript

// 2026-07-27 E2E 전수 순회 — 학습자 홈(learner-home) 영역 신규 스펙.
// docs/ops/e2e-full-sweep-2026-07-27.md §2에서 "검증: 신규 spec 필요"로 표기된
// 체크리스트 항목을 검증한다. 실 API 로그인(dev-login) 위에 route fixture로
// 페르소나·회기·대시보드 응답을 고정해 결정론적으로 UI 상태를 검증하며,
// 실제 AI 엔진 턴 생성은 일절 수행하지 않는다.
import { expect, test, type Page } from "@playwright/test";
import { signInAsLearner, useRealApi } from "./support";
const ACT_ID = "lhact001";
const REV_ID = "lhrev001";
const END_ID = "lhend001";
const ARC_ID = "lharc001";
const NOW = Date.now();
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
const HOUR = 60 * 60 * 1000;
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,
},
];
const S_ACT = {
session_id: ACT_ID,
session_no: 4,
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_REV = {
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
persona_name: "성하늘",
status: "ended",
stage: "정리",
started_at: iso(3 * HOUR),
ended_at: iso(2.5 * HOUR),
review_ready: true,
archived: false,
archived_at: null,
turn_count: 10,
learner_turn_count: 5,
client_turn_count: 5,
};
const S_END = {
session_id: END_ID,
session_no: 2,
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 S_ARC = {
session_id: ARC_ID,
session_no: 1,
persona_code: "P1",
persona_name: "성하늘",
status: "ended",
stage: "개입",
started_at: iso(8 * HOUR),
ended_at: iso(7 * HOUR),
review_ready: false,
archived: true,
archived_at: iso(6 * HOUR),
turn_count: 8,
learner_turn_count: 4,
client_turn_count: 4,
};
const SESSIONS_BODY = {
sessions: [S_ACT, S_REV, S_END, S_ARC],
source: "runtime",
};
const DASHBOARD_BODY = {
message: "학습 현황 요약",
source: "runtime",
overview: {
total_sessions: 4,
active_sessions: 1,
review_ready_sessions: 1,
completed_sessions: 3,
archived_sessions: 1,
learner_turns: 14,
client_turns: 14,
last_practiced_at: iso(1 * HOUR),
},
growth: {
trend: "down",
avg_rapport: 0.2,
avg_score: 0.6,
evaluated_sessions: 3,
latest_score: 0.62,
first_score: 0.7,
score_delta: -0.08,
top_techniques: ["감정 반영"],
points: [
{
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
stage: "정리",
started_at: iso(3 * HOUR),
ended_at: iso(2.5 * HOUR),
score: 0.62,
rapport: 0.2,
technique_count: 3,
watch_count: 2,
},
],
},
recent_feedback: [
{
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
persona_name: "성하늘",
stage: "정리",
score: 0.62,
rapport: 0.2,
note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
created_at: iso(2.5 * HOUR),
turn_seq: 6,
techniques: ["감정 반영"],
},
],
persona_progress: [
{
persona_code: "P1",
persona_name: "성하늘",
sessions: 3,
active_sessions: 1,
completed_sessions: 2,
review_ready_sessions: 1,
rapport_percent: 64,
trend: "down",
latest_stage: "탐색",
latest_score: 0.62,
latest_at: iso(1 * HOUR),
},
{
persona_code: "P2",
persona_name: "김도윤",
sessions: 1,
active_sessions: 0,
completed_sessions: 1,
review_ready_sessions: 0,
rapport_percent: 32,
trend: "insufficient",
latest_stage: "라포",
latest_score: null,
latest_at: iso(4.5 * HOUR),
},
],
achievements: [
{
id: "first-session",
label: "첫 회기 완료",
detail: "첫 회기를 끝냈습니다.",
state: "done",
},
{
id: "five-sessions",
label: "다섯 회기 도전",
detail: "다섯 번째 회기를 끝내면 열립니다.",
state: "locked",
},
],
};
const DETAIL_BODY = {
case_id: "case-lh",
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),
},
],
};
// 결정 D3 — 신규 학습자 기초 우선 추천 검증용 fixture.
// P1을 고난도, P4를 기초로 두어 API 순서(P1 먼저)와 난도 오름차순(P4 먼저)이 갈리게 한다.
const STARTER_END_ID = "lhstend1";
const STARTER_PERSONAS_BODY = [
{
code: "P1",
display_name: "성하늘",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "학업 스트레스와 수면 문제를 호소",
voice_preset: null,
source: "database",
degraded: false,
},
{
code: "P4",
display_name: "박새봄",
difficulty: "easy",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary: "출근길 불안과 긴장을 호소",
voice_preset: null,
source: "database",
degraded: false,
},
];
const S_STARTER_END = {
session_id: STARTER_END_ID,
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,
};
const EMPTY_DASHBOARD_BODY = {
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: [],
};
function jsonRoute(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
async function installLearnerHomeFixtures(
page: Page,
options: {
detail?: boolean;
dashboard?: typeof DASHBOARD_BODY;
} = {},
) {
const { detail = true, dashboard = DASHBOARD_BODY } = options;
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(jsonRoute(SESSIONS_BODY)),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(dashboard)),
);
if (detail) {
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
route.fulfill(jsonRoute(DETAIL_BODY)),
);
}
}
/** 결정 D3 검증용 — 난도 역순 페르소나 + 회기 0건(또는 P1 종료 1건)을 고정한다. */
async function installStarterFixtures(
page: Page,
options: { withRecords?: boolean } = {},
) {
const { withRecords = false } = options;
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(STARTER_PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(
jsonRoute({
sessions: withRecords ? [S_STARTER_END] : [],
source: "runtime",
}),
),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
);
if (withRecords) {
await page.route(new RegExp(`/api/sessions/${STARTER_END_ID}$`), (route) =>
route.fulfill(
jsonRoute({
...DETAIL_BODY,
session_id: STARTER_END_ID,
status: "ended",
stage: "라포",
ended_at: iso(4.5 * HOUR),
}),
),
);
}
}
async function installLongPersonaFixtures(page: Page) {
const personas = Array.from({ length: 16 }, (_, index) => {
const number = index + 1;
return {
code: `P${number}`,
display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`,
difficulty: number === 4 ? "easy" : "hard",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary:
"반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.",
voice_preset: null,
source: "database",
degraded: false,
};
});
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(EMPTY_DASHBOARD_BODY)),
);
}
test.describe("full-sweep learner home", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// checklist: learner-home-guard-pending-approval
test("redirects a pending learner from /learn to /pending", async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill(
jsonRoute({
user_id: "pending-learner",
email: "pending.learner@hs.ac.kr",
display_name: "Pending Learner",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "pending",
approval_required: true,
cohort_ids: [],
consent_at: 1,
onboarding_completed_at: 1,
nickname: "Pending Learner",
self_introduction: "",
avatar_url: "",
}),
),
);
await page.goto("/learn");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.getByText("승인 대기")).toBeVisible();
await expect(page.locator(".pa-status")).toHaveAttribute("role", "status");
await expect(
page.getByRole("button", { name: "승인 상태 새로고침" }),
).toBeVisible();
});
// checklist: learner-home-head-action
test("shows the action-focused dashboard header without a global count badge", async ({
page,
}) => {
await signInAsLearner(page);
await page.goto("/learn");
await expect(
page.getByRole("heading", { name: "오늘의 상담 연습" }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-head__status")).toHaveCount(0);
await expect(page.locator("main")).toHaveCount(1);
});
// checklist: learner-home-dash-metric-cards
test("renders four dashboard metric cards and routes them to history and practice", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(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);
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
"4회",
);
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
"상담 완료 3회 · 진행 중 1회",
);
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
"1건",
);
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
"62점",
);
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
"3회 평가 · 점검 필요",
);
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
"+0.20",
);
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
"성하늘 · 3회",
);
await cards.getByRole("button", { name: /전체 회기/ }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
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\/practice$/);
});
test("labels all sessions separately from completed counseling and empty ended sessions", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page, {
dashboard: {
...DASHBOARD_BODY,
overview: {
...DASHBOARD_BODY.overview,
total_sessions: 5,
completed_sessions: 2,
active_sessions: 0,
},
},
});
await page.goto("/learn");
await expect(
page.locator("#lh-panel-today .lh-dashboard-status"),
).toHaveCount(0);
await page.getByRole("tab", { name: "성장 지표" }).click();
const allSessions = page
.locator("#lh-panel-growth .lh-dashboard-status")
.getByRole("button", { name: "전체 회기" });
await expect(allSessions).toContainText("5회");
await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회");
await expect(allSessions).toContainText("대화 없이 종료한 회기 포함");
await expect(allSessions).toHaveAttribute(
"title",
"전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
);
});
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
test("shows the spotlight recap card and coach CTA for an active session", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 리캡 카드 — 우선순위 스포트라이트(진행 중 회기)의 아바타·단계·진행률·마지막 발화.
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(
"진행 중",
);
const recap = page.getByLabel("마지막 세션 리캡");
await expect(recap).toContainText("6턴");
await expect(recap).toContainText("탐색");
await expect(recap.locator(".lh-recap__progress")).toContainText("50%");
await expect(recap.locator(".lh-recap__quote")).toContainText(
"요즘은 잠이 잘 안 와요.",
);
await expect(recap.locator(".lh-recap__learner")).toContainText(
"안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
);
// AI 코치 카드 — 진행 중 회기 우선 규칙 문구 + 힌트 칩 + CTA가 세션으로 이동.
const coachCard = page.getByLabel("AI 코치");
await expect(
coachCard.getByRole("heading", {
name: "진행 중인 회기를 먼저 마무리합니다.",
}),
).toBeVisible();
await expect(coachCard.getByLabel("코칭 힌트")).toContainText(
"마지막 반응 먼저 반영",
);
const primaryAction = page.locator("[data-learner-primary-action]");
await expect(primaryAction).toHaveAccessibleName("이어하기");
await primaryAction.click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
});
test("naturalizes persisted privacy placeholders in the spotlight recap", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page, { detail: false });
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
route.fulfill(
jsonRoute({
...DETAIL_BODY,
turns: [
{
...DETAIL_BODY.turns[0],
text: "[NAME]에게 [PHONE]으로 연락해 볼까요?",
},
{
...DETAIL_BODY.turns[1],
text: "뭘… 할 수 있게 [NAME]는 건지 잘 [NAME]는데요. [ORG]에서 오라고 했어요.",
},
],
}),
),
);
await page.goto("/learn");
const recap = page.getByLabel("마지막 세션 리캡");
await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요");
await expect(recap).toContainText("소속 기관에서 오라고 했어요");
await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요");
for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) {
await expect(recap).not.toContainText(token);
}
});
// checklist: learner-home-dash-recommend-card, learner-home-dash-recent-feedback,
// learner-home-dash-review-queue
test("prioritizes review in the recommend card, recent feedback, and review queue", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 다음 연습 추천 — 리뷰 대기가 있으면 리뷰 우선 문구/이동.
const recommend = page.locator(".lh-dashboard-recommend");
await expect(
recommend.getByRole("heading", { name: "리뷰 확인을 우선합니다." }),
).toBeVisible();
await expect(recommend).toContainText("1건의 회기 피드백 확인");
await recommend.getByRole("button", { name: /리뷰 확인하기/ }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
// 최근 피드백 — 첫 항목 버튼이 해당 회기 리뷰로 이동.
await page.goto("/learn");
const feedback = page.locator(".lh-dashboard-feedback");
await expect(feedback.locator(".lh-panel__badge")).toHaveText("1건");
const feedbackRow = feedback.locator(".lh-feedback-mini__row");
const feedbackMeta = feedbackRow.locator("b > span");
await expect(feedbackMeta.nth(0)).toHaveText("P1 · 정리");
await expect(feedbackMeta.nth(1)).toHaveText("62점");
await expect(feedbackRow).toContainText(
"감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
);
await feedbackRow.click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
// 리뷰 대기 패널 — review_ready 상위 항목의 'AI 피드백' 버튼이 리뷰로 진입.
// 대시보드 셸은 전 폭에서 가로 탭으로 영역을 전환하므로 '기록 · 리뷰' 탭을 먼저 연다.
await page.goto("/learn");
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
await expect(
page.getByRole("heading", { name: "피드백 받을 기록입니다." }),
).toBeVisible();
const reviewButtons = page.getByRole("button", { name: "AI 피드백" });
await expect(reviewButtons).toHaveCount(1);
await reviewButtons.first().click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
});
// checklist: learner-home-dash-weakness-insights, learner-home-dash-rapport-progress,
// learner-home-dash-milestones
test("derives weakness insights, rapport bars, and milestone badges from dashboard data", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 성장 지표 패널들은 '성장 지표' 탭 아래에 배치된다(대시보드 셸 가로 탭).
await page.getByRole("tab", { name: "성장 지표" }).click();
// 보완할 부분 — growth 데이터에서 결정론으로 파생된 인사이트 최대 3건.
const weakness = page.locator(".lh-weakness");
await expect(weakness).toBeVisible();
const weaknessButtons = weakness.locator(".lh-weakness__list button");
await expect(weaknessButtons).toHaveCount(3);
await expect(weaknessButtons.nth(0)).toContainText("평가가 내려가는 흐름");
await expect(weaknessButtons.nth(1)).toContainText("라포 신호가 아직 약함");
await expect(weaknessButtons.nth(2)).toContainText("살펴볼 개입 2건");
// 라포 누적 — 회기가 있는 페르소나 상위 3명의 rapport_percent progressbar.
const rapport = page.getByLabel("페르소나별 라포 누적");
const bars = rapport.getByRole("progressbar");
await expect(bars).toHaveCount(2);
await expect(bars.nth(0)).toHaveAttribute("aria-valuenow", "64");
await expect(bars.nth(1)).toHaveAttribute("aria-valuenow", "32");
await expect(rapport).toContainText("성하늘");
await expect(rapport).toContainText("64%");
// 연습 이정표 — achievements 상태별 배지 + title 툴팁.
const milestones = page.locator(".lh-milestones");
await expect(milestones).toContainText("첫 회기 완료");
await expect(milestones.locator(".lh-milestone.is-done")).toHaveAttribute(
"title",
"첫 회기를 끝냈습니다.",
);
await expect(milestones.locator(".lh-milestone.is-locked")).toContainText(
"다섯 회기 도전",
);
// 인사이트 버튼 클릭 시 해당 화면으로 이동.
await weaknessButtons.nth(1).click();
await expect(page).toHaveURL(/\/learn\/practice$/);
});
// checklist: learner-home-dash-recap-fetch-states
test("shows recap detail loading, error fallback, and ignores stale detail responses", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page, { detail: false });
// 1) 로딩 → 성공: 상세 응답을 지연시켜 로딩 문구를 확인한 뒤 발화를 확정한다.
let releaseDetail!: () => void;
const detailGate = new Promise<void>((resolve) => {
releaseDetail = resolve;
});
const detailPattern = new RegExp(`/api/sessions/${ACT_ID}$`);
await page.route(detailPattern, async (route) => {
await detailGate;
await route.fulfill(jsonRoute(DETAIL_BODY));
});
await page.goto("/learn");
const quote = page.locator(".lh-recap__quote");
await expect(quote).toContainText("상세 발화를 확인하고 있습니다.");
releaseDetail();
await expect(quote).toContainText("요즘은 잠이 잘 안 와요.");
// 2) 오류: 상세 로드 실패 시 발화 없음 폴백 문구로 전환된다.
await page.unroute(detailPattern);
await page.route(detailPattern, (route) =>
route.fulfill(jsonRoute({ detail: "boom" }, 500)),
);
await page.reload();
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
// 3) stale 응답: session_id가 다른 상세는 무시되어 발화가 표시되지 않는다.
await page.unroute(detailPattern);
await page.route(detailPattern, (route) =>
route.fulfill(jsonRoute({ ...DETAIL_BODY, session_id: "other-session" })),
);
await page.reload();
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
await expect(quote).not.toContainText("요즘은 잠이 잘 안 와요.");
});
// checklist: learner-home-practice-persona-skeleton
test("shows persona skeletons while loading and a role=alert notice on failure", async ({
page,
}) => {
await signInAsLearner(page);
let releasePersonas!: () => void;
const personasGate = new Promise<void>((resolve) => {
releasePersonas = resolve;
});
await page.route(/\/api\/personas$/, async (route) => {
await personasGate;
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
});
await page.goto("/learn/practice");
// 로딩 중 스켈레톤 3개.
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3);
// 실패 시 role=alert 안내.
releasePersonas();
const alert = page.locator(".lh-error");
await expect(alert).toHaveAttribute("role", "alert");
await expect(alert).toContainText("내담자 목록을 불러오지 못했습니다.");
await expect(alert).toContainText("잠시 뒤 다시 시도해 주세요.");
});
// checklist: learner-home-session-list-states
test("distinguishes session list loading, error, and empty states", async ({
page,
}) => {
await signInAsLearner(page);
let releaseSessions!: () => void;
const sessionsGate = new Promise<void>((resolve) => {
releaseSessions = resolve;
});
const sessionsPattern = /\/api\/sessions$/;
await page.route(sessionsPattern, async (route) => {
await sessionsGate;
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
});
await page.goto("/learn/practice");
// 로딩 문구.
await expect(
page.getByText("저장된 연습 기록을 확인하고 있습니다."),
).toBeVisible();
// 오류: 인라인 경고(role=status).
releaseSessions();
const inlineAlert = page.locator(".lh-activity .lh-inline-alert");
await expect(inlineAlert).toContainText("연습 기록을 불러오지 못했습니다.");
await expect(inlineAlert).toHaveAttribute("role", "status");
// 빈 상태: 컨텍스트별 빈 메시지.
await page.unroute(sessionsPattern);
await page.route(sessionsPattern, (route) =>
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
);
await page.reload();
const empty = page.locator(".lh-activity__empty");
await expect(empty).toContainText("저장된 기존 회기가 없습니다.");
await expect(empty).toContainText(
"첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.",
);
await expect(empty).toHaveAttribute("role", "status");
});
// checklist: learner-home-history-overview-cards, learner-home-history-filter-chips,
// learner-home-history-search
test("filters history via overview cards, filter chips, and search", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn/history");
// 요약 필터 카드 4개 + 버킷 카운트 + aria-pressed.
const overview = page.locator(".lh-history-overview");
await expect(overview.getByRole("button")).toHaveCount(4);
await expect(overview.getByRole("button", { name: /전체/ })).toContainText(
"4",
);
await expect(
overview.getByRole("button", { name: /전체/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(
overview.getByRole("button", { name: /진행 중/ }),
).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
// 카드 클릭으로 필터 전환.
await overview.getByRole("button", { name: /진행 중/ }).click();
await expect(
overview.getByRole("button", { name: /진행 중/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
// 칩과 카드가 같은 필터 상태를 공유한다.
const chips = page.locator(".lh-history-filter");
await expect(chips.getByRole("button", { name: "진행 중" })).toHaveAttribute(
"aria-pressed",
"true",
);
await chips.getByRole("button", { name: "보관됨" }).click();
await expect(
overview.getByRole("button", { name: /보관됨/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.getByRole("button", { name: "복원" })).toBeVisible();
// 검색 — 단계 문자열 부분 일치 즉시 필터링.
await chips.getByRole("button", { name: "전체" }).click();
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
const search = page.getByLabel("기록 검색");
await search.fill("탐색");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.locator(".lh-session-list")).toContainText("탐색 단계");
await search.fill("존재하지않는검색어");
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
await expect(
page.getByText("조건에 맞는 회기가 없습니다."),
).toBeVisible();
});
// checklist: learner-home-history-archive-notice
test("surfaces archive failure and success notices with role=status", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
let archiveCalls = 0;
await page.route(new RegExp(`/api/sessions/${END_ID}/archive$`), (route) => {
archiveCalls += 1;
if (archiveCalls === 1) {
return route.fulfill(jsonRoute({ detail: "archive failed" }, 500));
}
return route.fulfill(
jsonRoute({
session_id: END_ID,
archived: true,
archived_at: iso(0),
session: { ...S_END, archived: true, archived_at: iso(0) },
source: "runtime",
}),
);
});
await page.goto("/learn/history");
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
await expect(endCard).toBeVisible();
// 실패: role=status 배너에 실패 문구.
await endCard.getByRole("button", { name: "보관", exact: true }).click();
const notice = page.locator(".lh-inline-alert--archive");
await expect(notice).toContainText("보관 상태를 바꾸지 못했습니다.");
await expect(notice).toHaveAttribute("role", "status");
// 성공: 같은 배너 위치에 성공 문구.
await endCard.getByRole("button", { name: "보관", exact: true }).click();
await expect(notice).toContainText("회기를 보관했습니다.");
await expect(notice).toHaveAttribute("role", "status");
});
// checklist: learner-home-history-persona-progress
test("lists persona progress rows and jumps to practice with the persona selected", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn/history");
const progress = page.getByLabel("페르소나별 진행 정도");
await expect(progress).toContainText("2명");
const rows = progress.locator(".lh-persona-progress__row");
await expect(rows).toHaveCount(2);
// 회기 수 내림차순 정렬 — P1(3회)이 먼저 온다.
await expect(rows.nth(0)).toContainText("P1");
const firstMeta = rows.nth(0).locator(".lh-persona-progress__meta");
await expect(firstMeta.locator(":scope > span").nth(0)).toHaveText(
"3회 · 평균 4턴",
);
await expect(firstMeta.locator(":scope > span").nth(1)).toHaveText(
"진행 1 · 리뷰 1",
);
await expect(
rows.nth(0).locator(".lh-persona-progress__latest"),
).toHaveText("진행 중 · 탐색");
await expect(rows.nth(1)).toContainText("P2");
// 행 클릭 시 해당 페르소나가 선택된 채 연습 화면으로 진입.
await rows.nth(1).click();
await expect(page).toHaveURL(/\/learn\/practice$/);
await expect(
page.getByRole("heading", { name: "김도윤" }),
).toBeVisible();
await expect(
page
.getByRole("listbox", { name: "연습 페르소나" })
.getByRole("option", { name: /P2/ }),
).toHaveAttribute("aria-selected", "true");
});
// checklist: learner-home-newbie-basic-first (결정 D3)
test("defaults a learner with no records to the easiest persona in dashboard and practice", async ({
page,
}) => {
await signInAsLearner(page);
await installStarterFixtures(page);
// (a) 대시보드 첫 회기 준비 카드가 API 순서(P1·고난도)가 아니라 기초(P4)를 보여준다.
await page.goto("/learn");
const focus = page.locator(".lh-session-focus");
await expect(focus).toContainText("첫 회기 준비");
await expect(focus.getByRole("heading", { name: "박새봄" })).toBeVisible();
await expect(focus).toContainText("기초");
await expect(page.getByLabel("마지막 세션 리캡")).toContainText("P4");
// (b) /learn/practice 기본 선택도 난도 오름차순 첫 사용 가능 페르소나(P4)다.
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(
listbox.getByRole("option", { name: /P4/ }),
).toHaveAttribute("aria-selected", "true");
await expect(
listbox.getByRole("option", { name: /P1/ }),
).toHaveAttribute("aria-selected", "false");
await expect(
page.getByRole("heading", { name: "박새봄" }),
).toBeVisible();
});
// checklist: learner-home-newbie-basic-first (결정 D3 — 기록 보유 시 무회귀)
test("keeps the recent-session rule when the learner already has records", async ({
page,
}) => {
await signInAsLearner(page);
await installStarterFixtures(page, { withRecords: true });
// (c) 기록이 있으면 스포트라이트는 최근 회기 페르소나(P1)를 유지한다.
await page.goto("/learn");
const focus = page.locator(".lh-session-focus");
await expect(focus).toContainText("오늘 이어갈 회기");
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
// 기본 선택도 기존 규칙(첫 사용 가능 페르소나 = P1)을 유지한다.
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(
listbox.getByRole("option", { name: /P1/ }),
).toHaveAttribute("aria-selected", "true");
await expect(
listbox.getByRole("option", { name: /P4/ }),
).toHaveAttribute("aria-selected", "false");
});
test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installStarterFixtures(page);
await page.setViewportSize({ width: 320, height: 844 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
await expect(selected).toHaveAttribute("aria-selected", "true");
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const subpixelTolerance = 1;
return (
optionRect.left >= listRect.left - subpixelTolerance &&
optionRect.right <= listRect.right + subpixelTolerance &&
optionRect.top >= listRect.top - subpixelTolerance &&
optionRect.bottom <= listRect.bottom + subpixelTolerance
);
})).toBe(true);
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
await page.screenshot({
path: testInfo.outputPath("p4-initial-mobile-320.png"),
fullPage: false,
animations: "disabled",
});
});
test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => {
await signInAsLearner(page);
await installStarterFixtures(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
await expect(selected).toHaveAttribute("aria-selected", "true");
await page.setViewportSize({ width: 320, height: 844 });
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const subpixelTolerance = 1;
return (
optionRect.left >= listRect.left - subpixelTolerance &&
optionRect.right <= listRect.right + subpixelTolerance &&
optionRect.top >= listRect.top - subpixelTolerance &&
optionRect.bottom <= listRect.bottom + subpixelTolerance
);
})).toBe(true);
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
});
test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installLongPersonaFixtures(page);
for (const width of [768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
const longPersona = listbox.getByRole("option", { name: /P12/ });
const lastPersona = listbox.getByRole("option", { name: /P16/ });
const startCta = page.getByRole("button", { name: "완전히 새로 시작" });
await expect(selected).toHaveAttribute("aria-selected", "true");
await expect(longPersona).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const options = [...list.querySelectorAll<HTMLElement>("[role='option']")];
const rowsDoNotOverlap = options.every((current, index) => {
const currentRect = current.getBoundingClientRect();
return options.slice(index + 1).every((other) => {
const otherRect = other.getBoundingClientRect();
return (
currentRect.right <= otherRect.left ||
otherRect.right <= currentRect.left ||
currentRect.bottom <= otherRect.top ||
otherRect.bottom <= currentRect.top
);
});
});
const contentFits = options.every((current) => {
const content = current.querySelector<HTMLElement>(".lh-persona__body");
return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom;
});
const hit = document.elementFromPoint(
optionRect.left + optionRect.width / 2,
optionRect.top + optionRect.height / 2,
);
return (
list.scrollHeight > list.clientHeight &&
getComputedStyle(list).overflowY === "auto" &&
optionRect.top >= listRect.top &&
optionRect.bottom <= listRect.bottom &&
rowsDoNotOverlap &&
contentFits &&
!!hit && option.contains(hit)
);
})).toBe(true);
await expect(startCta).toBeInViewport();
await page.screenshot({
path: testInfo.outputPath(`long-personas-w${width}-top.png`),
fullPage: false,
animations: "disabled",
});
await longPersona.scrollIntoViewIfNeeded();
await expect(longPersona).toBeInViewport();
await listbox.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(lastPersona).toBeInViewport();
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
await page.screenshot({
path: testInfo.outputPath(`long-personas-w${width}-bottom.png`),
fullPage: false,
animations: "disabled",
});
}
});
test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installStarterFixtures(page);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 });
await page.goto("/learn/history");
const overview = page.locator(".lh-history-overview");
const practiceCta = page.getByRole("button", { name: "상담 연습으로" });
const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ });
await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible();
await expect(practiceCta).toBeVisible();
await expect(toggle).toBeVisible();
const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length);
expect(columns).toBe(2);
for (const target of [practiceCta, toggle]) {
const height = await target.evaluate((element) => element.getBoundingClientRect().height);
expect(height).toBeGreaterThanOrEqual(44);
}
await toggle.scrollIntoViewIfNeeded();
await page.locator(".lh-persona-progress").screenshot({
path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`),
animations: "disabled",
});
await toggle.click();
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2);
await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click();
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0);
}
});
test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 });
await page.goto("/learn/history");
const filters = page.locator(".lh-history-filter button");
const historyActions = page.locator(".lh-session-actions button");
await expect(filters).toHaveCount(4);
await expect(historyActions).not.toHaveCount(0);
for (const target of [filters, historyActions]) {
const boxes = await target.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, width: rect.width };
}),
);
expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
if (width === 320) {
await page.screenshot({
path: testInfo.outputPath("history-top-mobile-320.png"),
fullPage: false,
animations: "disabled",
});
await historyActions.first().locator("..").locator("..").screenshot({
path: testInfo.outputPath("history-action-row-mobile-320.png"),
animations: "disabled",
});
}
await page.goto("/learn/practice");
const practiceActions = page.locator(".lh-session-actions button");
await expect(practiceActions).not.toHaveCount(0);
const practiceBoxes = await practiceActions.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, width: rect.width };
}),
);
expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
});
});