1219 lines
44 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|