전수 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:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
904
apps/web/e2e/full-sweep-learner-home.spec.ts
Normal file
904
apps/web/e2e/full-sweep-learner-home.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue