710 lines
26 KiB
TypeScript
710 lines
26 KiB
TypeScript
// uc-learner-practice-history — 학습자 연습(practice)·기록(history) 뷰 유스케이스 스펙.
|
|
//
|
|
// 목적: 학습자가 연습 대상을 고르고 새 회기를 시작하는 여정과, 회기 기록을
|
|
// 검색·필터·정렬 규칙에 따라 찾아 리뷰/재연습으로 진입하는 여정을 검증한다.
|
|
// 근거: apps/web/src/pages/LearnerHome.tsx(practice/history 뷰),
|
|
// apps/web/src/pages/learner-home/model.ts(정렬·라벨·빈 문구·날짜 표기 규칙).
|
|
// full-sweep-learner-home.spec.ts가 이미 다루는 필터 카드·칩 단독 전환,
|
|
// 아카이브 성공/실패 알림, 결정 D3 기본 케이스는 중복 커버하지 않고
|
|
// (검색+필터 조합, degraded 스킵, 난도 동률 code 순 등) 다른 각도로 비튼다.
|
|
// 모든 응답은 route fixture로 고정하며 실제 AI 엔진 턴 생성은 일절 없다.
|
|
import { test, expect, 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();
|
|
|
|
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-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: "2026-08-01T00:00:00+00:00",
|
|
onboarding_completed_at: "2026-08-01T00:00:00+00:00",
|
|
nickname: "학습자",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── 페르소나 fixture ─────────────────────────────────────────────────────────
|
|
function makePersona(
|
|
code: string,
|
|
displayName: string,
|
|
difficulty: string,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
code,
|
|
display_name: displayName,
|
|
difficulty,
|
|
theory_target: ["humanistic"],
|
|
demographics: { age_band: "20대" },
|
|
presenting_summary: `${displayName}의 주호소 요약`,
|
|
voice_preset: null,
|
|
source: "database",
|
|
degraded: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const PERSONAS_BODY = [
|
|
makePersona("P1", "성하늘", "easy", {
|
|
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
|
}),
|
|
makePersona("P2", "김도윤", "moderate", {
|
|
theory_target: ["cbt"],
|
|
demographics: { age_band: "30대" },
|
|
presenting_summary: "직장 내 대인관계 갈등을 호소",
|
|
}),
|
|
];
|
|
|
|
// ── 회기 fixture ────────────────────────────────────────────────────────────
|
|
function makeSession(
|
|
sessionId: string,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
session_id: sessionId,
|
|
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,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const ACT_ID = "ucact001";
|
|
const REV_ID = "ucrev001";
|
|
const END_ID = "ucend001";
|
|
const ARC_ID = "ucarc001";
|
|
|
|
const S_ACT = makeSession(ACT_ID, {
|
|
session_no: 4,
|
|
status: "active",
|
|
stage: "탐색",
|
|
started_at: iso(1 * HOUR),
|
|
ended_at: null,
|
|
turn_count: 6,
|
|
learner_turn_count: 3,
|
|
client_turn_count: 3,
|
|
});
|
|
const S_REV = makeSession(REV_ID, {
|
|
session_no: 3,
|
|
stage: "정리",
|
|
started_at: iso(3 * HOUR),
|
|
ended_at: iso(2.5 * HOUR),
|
|
review_ready: true,
|
|
turn_count: 10,
|
|
learner_turn_count: 5,
|
|
client_turn_count: 5,
|
|
});
|
|
const S_END = makeSession(END_ID, {
|
|
session_no: 2,
|
|
persona_code: "P2",
|
|
persona_name: "김도윤",
|
|
stage: "라포",
|
|
});
|
|
const S_ARC = makeSession(ARC_ID, {
|
|
session_no: 1,
|
|
stage: "개입",
|
|
started_at: iso(8 * HOUR),
|
|
ended_at: iso(7 * HOUR),
|
|
archived: true,
|
|
archived_at: iso(6 * HOUR),
|
|
turn_count: 8,
|
|
learner_turn_count: 4,
|
|
client_turn_count: 4,
|
|
});
|
|
|
|
const BASE_SESSIONS = [S_ACT, S_REV, S_END, S_ARC];
|
|
|
|
// 대시보드는 practice 통계(진행/리뷰/누적)와 history 카운트의 우선 소스라
|
|
// 회기 fixture에서 결정론으로 파생해 응답 모양을 실제 타입과 맞춘다.
|
|
function makeDashboard(sessions: Array<Record<string, unknown>>) {
|
|
const active = sessions.filter((s) => s.status === "active").length;
|
|
const ended = sessions.filter((s) => s.status === "ended").length;
|
|
const review = sessions.filter(
|
|
(s) => s.status === "ended" && s.review_ready && !s.archived,
|
|
).length;
|
|
const archived = sessions.filter((s) => s.archived).length;
|
|
return {
|
|
message: "학습 현황 요약",
|
|
source: "runtime",
|
|
overview: {
|
|
total_sessions: sessions.length,
|
|
active_sessions: active,
|
|
review_ready_sessions: review,
|
|
completed_sessions: ended,
|
|
archived_sessions: archived,
|
|
learner_turns: 0,
|
|
client_turns: 0,
|
|
last_practiced_at: sessions.length ? iso(1 * HOUR) : 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: [],
|
|
};
|
|
}
|
|
|
|
async function installFixtures(
|
|
page: Page,
|
|
options: {
|
|
personas?: Array<Record<string, unknown>>;
|
|
sessions?: Array<Record<string, unknown>>;
|
|
} = {},
|
|
) {
|
|
const personas = options.personas ?? PERSONAS_BODY;
|
|
const sessions = options.sessions ?? BASE_SESSIONS;
|
|
await routeUnmockedApi(page);
|
|
await routeAuthedLearner(page);
|
|
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(makeDashboard(sessions))),
|
|
);
|
|
}
|
|
|
|
const personaListbox = (page: Page) =>
|
|
page.getByRole("listbox", { name: "연습 페르소나" });
|
|
|
|
test.describe("uc learner practice/history", () => {
|
|
// usecase: 학습자가 연습 화면에서 다른 내담자를 고른 뒤 새 회기를 시작한다.
|
|
test("페르소나를 선택하고 새 회기를 시작하면 해당 내담자 세션으로 이동한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/practice");
|
|
|
|
const listbox = personaListbox(page);
|
|
// 기록 보유 학습자 → 기본 선택은 API 순서 첫 사용 가능 페르소나(P1).
|
|
await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
|
|
await listbox.getByRole("option", { name: /P2/ }).click();
|
|
await expect(listbox.getByRole("option", { name: /P2/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
// 미리보기 제목·주호소가 선택 페르소나로 갱신된다.
|
|
await expect(page.getByRole("heading", { name: "김도윤" })).toBeVisible();
|
|
await expect(page.locator(".lh-summary p")).toHaveText(
|
|
"직장 내 대인관계 갈등을 호소",
|
|
);
|
|
|
|
await page.getByRole("button", { name: "완전히 새로 시작" }).click();
|
|
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/);
|
|
});
|
|
|
|
// usecase: 기록이 없는 신규 학습자에게 기초 난도부터 추천하되, 사용 불가
|
|
// (degraded) 페르소나는 건너뛰고 다음 사용 가능한 기초 페르소나를 고른다.
|
|
test("신규 학습자 기본 추천은 사용 불가 기초 페르소나를 건너뛴다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, {
|
|
personas: [
|
|
makePersona("P9", "한겨울", "hard"),
|
|
makePersona("P2", "김도윤", "easy", { degraded: true }),
|
|
makePersona("P5", "박새봄", "easy"),
|
|
],
|
|
sessions: [],
|
|
});
|
|
await page.goto("/learn/practice");
|
|
|
|
const listbox = personaListbox(page);
|
|
await expect(listbox.getByRole("option", { name: /P5/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await expect(listbox.getByRole("option", { name: /P9/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"false",
|
|
);
|
|
await expect(page.getByRole("heading", { name: "박새봄" })).toBeVisible();
|
|
});
|
|
|
|
// usecase: 신규 학습자에게 같은 난도가 여러 명이면 code 오름차순 첫 번째를 추천한다.
|
|
test("신규 학습자 기본 추천은 난도 동률이면 코드 오름차순으로 고른다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, {
|
|
// API 응답 순서는 PB가 먼저 — D3가 없으면 PB가 기본 선택된다.
|
|
personas: [
|
|
makePersona("PB", "이가을", "easy"),
|
|
makePersona("PA", "정봄이", "easy"),
|
|
],
|
|
sessions: [],
|
|
});
|
|
await page.goto("/learn/practice");
|
|
|
|
const listbox = personaListbox(page);
|
|
await expect(listbox.getByRole("option", { name: /PA/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await expect(listbox.getByRole("option", { name: /PB/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"false",
|
|
);
|
|
});
|
|
|
|
// usecase: 사용 불가 페르소나는 목록에서 구분 표기되고 선택할 수 없다.
|
|
test("사용 불가 페르소나는 비활성 표기되고 클릭해도 선택되지 않는다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, {
|
|
personas: [
|
|
makePersona("P1", "성하늘", "easy"),
|
|
makePersona("P3", "최다온", "moderate", { degraded: true }),
|
|
],
|
|
sessions: [],
|
|
});
|
|
await page.goto("/learn/practice");
|
|
|
|
const listbox = personaListbox(page);
|
|
const degradedOption = listbox.getByRole("option", { name: /P3/ });
|
|
await expect(degradedOption).toBeDisabled();
|
|
await expect(degradedOption).toHaveAttribute("aria-disabled", "true");
|
|
await expect(degradedOption.locator(".lh-persona__flag")).toHaveText(
|
|
"사용 불가",
|
|
);
|
|
await expect(degradedOption).toContainText(
|
|
"카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.",
|
|
);
|
|
|
|
// 비활성 버튼 클릭 시도 후에도 선택은 P1을 유지한다.
|
|
await degradedOption.click({ force: true });
|
|
await expect(listbox.getByRole("option", { name: /P1/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await expect(degradedOption).toHaveAttribute("aria-selected", "false");
|
|
});
|
|
|
|
// usecase: 연습 화면에서 페르소나를 바꾸면 '기존 회기' 목록이 그 내담자
|
|
// 기록으로 갱신되어 이어가기/리뷰 판단을 돕는다.
|
|
test("페르소나 선택에 따라 기존 회기 목록이 해당 내담자 기록으로 바뀐다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/practice");
|
|
|
|
const recent = page.locator(".lh-activity__recent");
|
|
// 기본 선택 P1 — P1 회기 3건(진행·리뷰·보관).
|
|
// head는 Kicker("기존 회기")와 카운트 span 두 개라 마지막 span을 읽는다.
|
|
await expect(recent.locator(".lh-activity__head > span").last()).toHaveText(
|
|
"P1 · 3회",
|
|
);
|
|
await expect(recent.locator(".lh-session-card")).toHaveCount(3);
|
|
await expect(recent).toContainText("성하늘");
|
|
await expect(recent).not.toContainText("김도윤");
|
|
|
|
await personaListbox(page).getByRole("option", { name: /P2/ }).click();
|
|
await expect(recent.locator(".lh-activity__head > span").last()).toHaveText(
|
|
"P2 · 1회",
|
|
);
|
|
await expect(recent.locator(".lh-session-card")).toHaveCount(1);
|
|
await expect(recent).toContainText("김도윤");
|
|
});
|
|
|
|
// usecase: 학습자가 연습 화면 상단 통계로 진행/리뷰/누적 현황을 한눈에 본다.
|
|
test("연습 화면 통계가 진행 중·리뷰 가능·누적 회기 수를 표시한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/practice");
|
|
|
|
const stats = page.locator(".lh-activity__stats > span");
|
|
await expect(stats).toHaveCount(3);
|
|
await expect(stats.nth(0)).toContainText("진행 중");
|
|
await expect(stats.nth(0).locator("b")).toHaveText("1");
|
|
await expect(stats.nth(1)).toContainText("리뷰 가능");
|
|
await expect(stats.nth(1).locator("b")).toHaveText("1");
|
|
await expect(stats.nth(2)).toContainText("누적 회기");
|
|
await expect(stats.nth(2).locator("b")).toHaveText("4");
|
|
});
|
|
|
|
// usecase: 기록 화면에서 각 회기 카드의 상태(진행 중/리뷰 가능/종료/보관됨)와
|
|
// 다음 행동 버튼(이어하기/리뷰/기록)을 구분해 읽는다.
|
|
test("기록 카드가 상태별 라벨과 행동 버튼을 구분해 표시한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history");
|
|
|
|
const cards = page.locator(".lh-session-list .lh-session-card");
|
|
await expect(cards).toHaveCount(4);
|
|
|
|
// 정렬(ended_at ?? started_at 내림차순): 진행중 → 리뷰 → 종료 → 보관.
|
|
const expectations = [
|
|
{ status: "진행 중", bucket: "이어갈 회기", action: "이어하기" },
|
|
{ status: "리뷰 가능", bucket: "AI 피드백 대기", action: "리뷰" },
|
|
{ status: "종료", bucket: "종료 기록", action: "기록" },
|
|
{ status: "보관됨", bucket: "보관 처리됨", action: "기록" },
|
|
];
|
|
for (const [index, item] of expectations.entries()) {
|
|
const card = cards.nth(index);
|
|
await expect(card.locator(".lh-session-card__status b")).toHaveText(
|
|
item.status,
|
|
);
|
|
await expect(card.locator(".lh-session-card__status small")).toHaveText(
|
|
item.bucket,
|
|
);
|
|
await expect(
|
|
card.getByRole("button", { name: item.action, exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
// 진행 중 카드에는 보관·다음 회기 버튼이 없고, 종료 카드에는 있다.
|
|
await expect(
|
|
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
cards
|
|
.nth(0)
|
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
cards
|
|
.nth(2)
|
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
cards.nth(3).getByRole("button", { name: "복원", exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// usecase: 리뷰 준비된 기록 카드에서 '리뷰'를 눌러 회기 리뷰로 진입한다.
|
|
test("기록 카드의 리뷰 버튼이 해당 회기 리뷰 화면으로 이동한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history");
|
|
|
|
const revCard = page.locator(".lh-session-card", {
|
|
hasText: "AI 피드백 대기",
|
|
});
|
|
await expect(revCard).toHaveCount(1);
|
|
await revCard.getByRole("button", { name: "리뷰", exact: true }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
|
});
|
|
|
|
test("검색과 리뷰 필터를 URL에 보존해 리뷰 왕복과 새로고침 뒤에도 복구한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history?source=dashboard");
|
|
|
|
const search = page.getByLabel("기록 검색");
|
|
const chips = page.locator(".lh-history-filter");
|
|
const historyLength = await page.evaluate(() => window.history.length);
|
|
await search.pressSequentially("성하늘");
|
|
await expect(search).toHaveValue("성하늘");
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("q"))
|
|
.toBe("성하늘");
|
|
await expect
|
|
.poll(() => page.evaluate(() => window.history.length))
|
|
.toBe(historyLength);
|
|
|
|
await chips.getByRole("button", { name: "리뷰 필요" }).click();
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("status"))
|
|
.toBe("review");
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("source"))
|
|
.toBe("dashboard");
|
|
|
|
const revCard = page.locator(".lh-session-card", {
|
|
hasText: "AI 피드백 대기",
|
|
});
|
|
await revCard.getByRole("button", { name: "리뷰", exact: true }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
|
|
|
await page.goBack();
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("q"))
|
|
.toBe("성하늘");
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("status"))
|
|
.toBe("review");
|
|
await expect(search).toHaveValue("성하늘");
|
|
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(page.locator(".lh-session-card")).toHaveCount(1);
|
|
|
|
await page.reload();
|
|
await expect(search).toHaveValue("성하늘");
|
|
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
|
|
await search.fill("");
|
|
await chips.getByRole("button", { name: "전체" }).click();
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.has("q"))
|
|
.toBe(false);
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.has("status"))
|
|
.toBe(false);
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("source"))
|
|
.toBe("dashboard");
|
|
});
|
|
|
|
test("잘못된 상태 query는 전체 필터로 안전하게 처리한다", async ({ page }) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history?status=unknown&q=%EA%B9%80%EB%8F%84%EC%9C%A4&source=dashboard");
|
|
|
|
const chips = page.locator(".lh-history-filter");
|
|
await expect(page.getByLabel("기록 검색")).toHaveValue("김도윤");
|
|
await expect(chips.getByRole("button", { name: "전체" })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(page.locator(".lh-session-card")).toHaveCount(1);
|
|
|
|
await chips.getByRole("button", { name: "리뷰 필요" }).click();
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("status"))
|
|
.toBe("review");
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("q"))
|
|
.toBe("김도윤");
|
|
await expect
|
|
.poll(() => new URL(page.url()).searchParams.get("source"))
|
|
.toBe("dashboard");
|
|
});
|
|
|
|
// usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다.
|
|
test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history");
|
|
|
|
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
|
await expect(endCard).toHaveCount(1);
|
|
await endCard
|
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true })
|
|
.click();
|
|
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=continue$/);
|
|
});
|
|
|
|
// usecase: 상태 필터 칩과 검색어를 함께 걸어 조건을 좁히고, 조건이 어긋나면
|
|
// 빈 결과 문구를 확인한 뒤 필터를 되돌려 결과를 다시 찾는다.
|
|
test("검색어와 상태 필터 칩을 조합해 기록을 좁힌다", async ({ page }) => {
|
|
await installFixtures(page);
|
|
await page.goto("/learn/history");
|
|
|
|
const head = page.locator(".lh-history-main__head");
|
|
const chips = page.locator(".lh-history-filter");
|
|
const search = page.getByLabel("기록 검색");
|
|
|
|
// 진행 중 필터 + 종료 회기에만 있는 검색어 → 교집합 0건.
|
|
await chips.getByRole("button", { name: "진행 중" }).click();
|
|
await search.fill("김도윤");
|
|
await expect(head).toContainText("0개");
|
|
await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible();
|
|
|
|
// 같은 검색어로 필터만 전체로 되돌리면 1건이 다시 보인다.
|
|
await chips.getByRole("button", { name: "전체" }).click();
|
|
await expect(head).toContainText("1개");
|
|
await expect(
|
|
page.locator(".lh-session-list .lh-session-card"),
|
|
).toContainText("김도윤");
|
|
|
|
// 검색은 상태 라벨("보관됨")로도 매칭된다.
|
|
await search.fill("보관");
|
|
await expect(head).toContainText("1개");
|
|
await expect(
|
|
page
|
|
.locator(".lh-session-list .lh-session-card")
|
|
.locator(".lh-session-card__status b"),
|
|
).toHaveText("보관됨");
|
|
});
|
|
|
|
// usecase: 기록 목록의 기본 정렬은 종료 시각 우선 최신순이라, 먼저 시작했어도
|
|
// 나중에 끝난 회기가 목록 맨 위로 온다.
|
|
test("기록 목록은 종료 시각(없으면 시작 시각) 기준 최신순으로 정렬된다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, {
|
|
sessions: [
|
|
// 시작은 가장 이르지만 종료가 가장 최신 → 1위여야 한다.
|
|
makeSession("ucsortA1", {
|
|
persona_code: "PA",
|
|
persona_name: "정렬첫째",
|
|
started_at: iso(10 * HOUR),
|
|
ended_at: iso(1 * HOUR),
|
|
}),
|
|
// 진행 중(ended_at 없음) → started_at(2시간 전) 기준 2위.
|
|
makeSession("ucsortB1", {
|
|
persona_code: "PB",
|
|
persona_name: "정렬둘째",
|
|
status: "active",
|
|
stage: "탐색",
|
|
started_at: iso(2 * HOUR),
|
|
ended_at: null,
|
|
}),
|
|
// 종료 3시간 전 → 3위.
|
|
makeSession("ucsortC1", {
|
|
persona_code: "PC",
|
|
persona_name: "정렬셋째",
|
|
started_at: iso(4 * HOUR),
|
|
ended_at: iso(3 * HOUR),
|
|
}),
|
|
],
|
|
});
|
|
await page.goto("/learn/history");
|
|
|
|
const cards = page.locator(".lh-session-list .lh-session-card");
|
|
await expect(cards).toHaveCount(3);
|
|
await expect(cards.nth(0)).toContainText("정렬첫째");
|
|
await expect(cards.nth(1)).toContainText("정렬둘째");
|
|
await expect(cards.nth(2)).toContainText("정렬셋째");
|
|
});
|
|
|
|
// usecase: 리뷰할 회기가 없는 상태에서 '리뷰 필요' 필터를 열면 검색어 없는
|
|
// 필터 전용 빈 문구를 본다.
|
|
test("리뷰 필요 필터에 해당 기록이 없으면 전용 빈 문구를 보여준다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, { sessions: [S_ACT, S_END] });
|
|
await page.goto("/learn/history");
|
|
|
|
const overview = page.locator(".lh-history-overview");
|
|
await expect(overview.getByRole("button", { name: /리뷰 필요/ })).toContainText(
|
|
"0",
|
|
);
|
|
await page
|
|
.locator(".lh-history-filter")
|
|
.getByRole("button", { name: "리뷰 필요" })
|
|
.click();
|
|
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
|
|
await expect(
|
|
page.getByText("리뷰가 필요한 회기가 없습니다."),
|
|
).toBeVisible();
|
|
|
|
// 검색어가 있으면 문구가 검색 전용 문구로 바뀐다.
|
|
await page.getByLabel("기록 검색").fill("성하늘");
|
|
await expect(page.getByText("조건에 맞는 회기가 없습니다.")).toBeVisible();
|
|
});
|
|
|
|
// usecase: 오래 연습한 학습자가 100개 기록을 끝까지 스크롤해 살펴본다.
|
|
test("100개 기록도 전부 렌더링되고 끝까지 스크롤해도 가로 넘침이 없다", async ({
|
|
page,
|
|
}) => {
|
|
const bulk = Array.from({ length: 100 }, (_, i) =>
|
|
makeSession(`ucbulk${String(i).padStart(3, "0")}`, {
|
|
session_no: 100 - i,
|
|
persona_code: i % 2 === 0 ? "P1" : "P2",
|
|
persona_name: i % 2 === 0 ? "성하늘" : "김도윤",
|
|
stage: (["라포", "탐색", "개입", "정리"] as const)[i % 4],
|
|
started_at: iso((i + 2) * HOUR),
|
|
ended_at: iso((i + 2) * HOUR - 30 * 60 * 1000),
|
|
}),
|
|
);
|
|
await installFixtures(page, { sessions: bulk });
|
|
await page.goto("/learn/history");
|
|
|
|
await expect(page.locator(".lh-history-main__head")).toContainText("100개");
|
|
const cards = page.locator(".lh-session-list .lh-session-card");
|
|
await expect(cards).toHaveCount(100);
|
|
|
|
await cards.nth(99).scrollIntoViewIfNeeded();
|
|
await expect(cards.nth(99)).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
// usecase: 기록 카드에서 회기 시작 시각을 "N월 N일 오전/오후 HH:MM" 형식으로
|
|
// 읽고, 손상된 날짜 값은 깨진 문구 없이 조용히 비워진다.
|
|
test("기록 카드가 날짜·시간을 한국어 형식으로 표기하고 잘못된 날짜는 비운다", async ({
|
|
page,
|
|
}) => {
|
|
await installFixtures(page, {
|
|
sessions: [
|
|
makeSession("ucdate001", {
|
|
persona_name: "날짜확인",
|
|
started_at: iso(2 * HOUR),
|
|
ended_at: iso(1.5 * HOUR),
|
|
}),
|
|
makeSession("ucdate002", {
|
|
persona_name: "손상날짜",
|
|
started_at: "not-a-date",
|
|
ended_at: null,
|
|
}),
|
|
],
|
|
});
|
|
await page.goto("/learn/history");
|
|
|
|
const dated = page.locator(".lh-session-card", { hasText: "날짜확인" });
|
|
const dateSpan = dated.locator(".lh-session-card__body > p > span").nth(1);
|
|
// sessionDateLabel: Intl ko-KR { month:short, day:numeric, hour/minute 2-digit }.
|
|
await expect(dateSpan).toHaveText(
|
|
/^\d{1,2}월 \d{1,2}일 (오전|오후) \d{2}:\d{2}$/,
|
|
);
|
|
// 단계·턴 표기와 날짜가 한 줄에 함께 놓인다.
|
|
await expect(
|
|
dated.locator(".lh-session-card__body > p > span").nth(0),
|
|
).toHaveText("라포 단계 · 4턴");
|
|
|
|
const broken = page.locator(".lh-session-card", { hasText: "손상날짜" });
|
|
await expect(
|
|
broken.locator(".lh-session-card__body > p > span").nth(1),
|
|
).toHaveText("");
|
|
await expect(broken).not.toContainText("Invalid");
|
|
});
|
|
});
|