전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영

- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
This commit is contained in:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -0,0 +1,904 @@
// 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 } = {},
) {
const { detail = true } = 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_BODY)),
);
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),
}),
),
);
}
}
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-status-live
test("announces persona and history counts in the aria-live head status", async ({
page,
}) => {
await signInAsLearner(page);
await page.goto("/learn");
const status = page.locator(".lh-head__status");
await expect(status).toHaveAttribute("aria-live", "polite");
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
});
// 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");
const cards = page.locator(".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
.locator(".lh-dashboard-status")
.getByRole("button", { name: /라포 흐름/ })
.click();
await expect(page).toHaveURL(/\/learn\/practice$/);
});
// 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(
"마지막 반응 먼저 반영",
);
await coachCard.getByRole("button", { name: "이어하기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
});
// 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");
await expect(feedbackRow).toContainText("P1 · 정리 · 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");
await expect(rows.nth(0)).toContainText(
"3회 · 1회 진행 · 1회 리뷰 · 평균 4턴",
);
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");
});
});