vignette/apps/web/e2e/uc-learner-practice-history.spec.ts
Yun Chan 1306c524c1
Some checks failed
API contract / OpenAPI type drift (push) Failing after 4m46s
회기 집계와 기록 복귀 및 감독 근거 링크 수정
2026-09-09 14:41:54 +09:00

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");
});
});