- 전수 순회: 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차 노트.
823 lines
27 KiB
TypeScript
823 lines
27 KiB
TypeScript
/**
|
|
* full-sweep-professor.spec.ts — 2026-07-27 E2E 전수 순회에서 "신규 spec 필요"로
|
|
* 표기된 교수자 콘솔(/teach, /teach/analysis) 체크리스트 항목 검증.
|
|
* (docs/ops/e2e-full-sweep-2026-07-27.md · "## 5. 교수자 콘솔 (professor)")
|
|
*
|
|
* 커버 항목:
|
|
* - professor-guard-pending-approval / professor-guard-onboarding
|
|
* - professor-dashboard-error-banner / professor-header-refresh / professor-kpi-cards
|
|
* - professor-persona-review-error-retry / professor-persona-review-empty-loading
|
|
* - professor-analysis-sort-segment / professor-analysis-table-states
|
|
* - professor-analysis-back-to-list / professor-analysis-invalid-learner-redirect
|
|
* - professor-analysis-trend-chart / professor-analysis-stage-breakdown
|
|
*
|
|
* UI 상태 고정은 page.route fixture로 처리하고, 실 API로 검증 가능한 가드류는
|
|
* dev-login 실 세션을 사용한다. AI 엔진 턴 생성은 하지 않는다.
|
|
*/
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { signInAsTeacher } from "./support";
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function authMeFixture(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
user_id: "00000000-0000-0000-0000-000000000901",
|
|
email: "teacher@hs.ac.kr",
|
|
role: "teacher",
|
|
display_name: "E2E Teacher",
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: [],
|
|
consent_at: 1782820000,
|
|
onboarding_completed_at: 1782820001,
|
|
nickname: "E2E Teacher",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function dashboardFixture(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
source: "database",
|
|
cohort_label: "E2E cohort",
|
|
total_learners: 0,
|
|
active_sessions: 0,
|
|
ended_sessions: 0,
|
|
learner_growth: [],
|
|
safety_alerts: [],
|
|
pending_reviews: [],
|
|
recent_sessions: [],
|
|
message: "검토할 실제 회기가 없습니다.",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function pendingReviewFixture(index: number) {
|
|
const padded = String(index).padStart(2, "0");
|
|
return {
|
|
session_id: `professor-sweep-pending-${padded}`,
|
|
learner_id: `00000000-0000-0000-0000-0000000001${padded}`,
|
|
learner_label: `검토 학습자 ${padded}`,
|
|
persona_code: "P1",
|
|
persona_name: "서연",
|
|
session_no: index,
|
|
status: "ended",
|
|
stage: "정리",
|
|
turn_count: 6 + index,
|
|
learner_turn_count: 3,
|
|
client_turn_count: 3,
|
|
started_at: `2026-07-2${index}T07:00:00Z`,
|
|
ended_at: `2026-07-2${index}T07:32:00Z`,
|
|
review_status: "pending",
|
|
review_note: null,
|
|
reviewed_at: null,
|
|
};
|
|
}
|
|
|
|
function recentSessionFixture(index: number) {
|
|
const padded = String(index).padStart(2, "0");
|
|
return {
|
|
session_id: `professor-sweep-recent-${padded}`,
|
|
learner_id: `00000000-0000-0000-0000-0000000002${padded}`,
|
|
learner_label: `최근 학습자 ${padded}`,
|
|
persona_code: "P2",
|
|
persona_name: "민재",
|
|
session_no: index,
|
|
status: index % 2 === 0 ? "active" : "ended",
|
|
stage: "탐색",
|
|
turn_count: 4 + index,
|
|
learner_turn_count: 2 + index,
|
|
client_turn_count: 2,
|
|
started_at: `2026-07-2${index}T08:00:00Z`,
|
|
ended_at: index % 2 === 0 ? null : `2026-07-2${index}T08:40:00Z`,
|
|
};
|
|
}
|
|
|
|
function safetyAlertFixture(index: number) {
|
|
return {
|
|
id: `professor-sweep-alert-${index}`,
|
|
session_id: `professor-sweep-crisis-${index}`,
|
|
learner_id: `00000000-0000-0000-0000-0000000003${String(index).padStart(2, "0")}`,
|
|
learner_label: `위기 학습자 ${index}`,
|
|
persona_code: "P1",
|
|
ko_risk_level: "높음",
|
|
created_at: "2026-07-26T10:00:00Z",
|
|
resource_title: "자살예방 상담전화",
|
|
resource_number: "109",
|
|
};
|
|
}
|
|
|
|
function reviewPersonaFixture(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
persona_id: "00000000-0000-0000-0000-000000009911",
|
|
code: "P13",
|
|
version: 1,
|
|
status: "review",
|
|
display_name: "전수 검수 대기 페르소나",
|
|
difficulty: "moderate",
|
|
theory_target: ["humanistic"],
|
|
source_provenance: "full-sweep fixture",
|
|
is_synthetic: true,
|
|
created_at: "2026-07-25T07:00:00Z",
|
|
approved_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function growthLearnerFixture(
|
|
id: string,
|
|
label: string,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
learner_id: id,
|
|
learner_label: label,
|
|
sessions: 1,
|
|
ended_sessions: 1,
|
|
latest_at: "2026-07-01T09:00:00Z",
|
|
first_score: 0.4,
|
|
latest_score: 0.5,
|
|
score_delta: 0.1,
|
|
avg_score: 0.45,
|
|
avg_rapport: 0.2,
|
|
trend: "flat",
|
|
top_techniques: ["reflection"],
|
|
points: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function growthPointFixture(
|
|
learnerId: string,
|
|
index: number,
|
|
score: number,
|
|
rapport: number,
|
|
) {
|
|
return {
|
|
session_id: `${learnerId}-session-${index}`,
|
|
session_no: index,
|
|
persona_code: "P1",
|
|
stage: index > 1 ? "개입" : "탐색",
|
|
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
|
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
|
score,
|
|
rapport,
|
|
technique_count: index + 1,
|
|
watch_count: 0,
|
|
};
|
|
}
|
|
|
|
function analysisSessionFixture(learnerId: string, label: string, index: number) {
|
|
return {
|
|
session_id: `${learnerId}-session-${index}`,
|
|
learner_id: learnerId,
|
|
learner_label: label,
|
|
persona_code: "P1",
|
|
persona_name: "서연",
|
|
session_no: index,
|
|
status: "ended",
|
|
stage: index > 1 ? "개입" : "탐색",
|
|
turn_count: 8 + index,
|
|
learner_turn_count: 4 + index,
|
|
client_turn_count: 4,
|
|
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
|
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
|
review_status: "pending",
|
|
review_note: null,
|
|
reviewed_at: null,
|
|
};
|
|
}
|
|
|
|
function learnerAnalysisFixture(learnerId: string, label: string) {
|
|
const points = [
|
|
growthPointFixture(learnerId, 1, 0.5, 0.2),
|
|
growthPointFixture(learnerId, 2, 0.8, 0.4),
|
|
];
|
|
const sessions = [
|
|
analysisSessionFixture(learnerId, label, 1),
|
|
analysisSessionFixture(learnerId, label, 2),
|
|
];
|
|
return {
|
|
source: "database",
|
|
learner_id: learnerId,
|
|
learner_label: label,
|
|
total_sessions: sessions.length,
|
|
active_sessions: 0,
|
|
ended_sessions: sessions.length,
|
|
pending_reviews: sessions.length,
|
|
closed_reviews: 0,
|
|
summary: {
|
|
learner_id: learnerId,
|
|
learner_label: label,
|
|
sessions: sessions.length,
|
|
ended_sessions: sessions.length,
|
|
latest_at: "2026-07-12T09:30:00Z",
|
|
first_score: 0.5,
|
|
latest_score: 0.8,
|
|
score_delta: 0.3,
|
|
avg_score: 0.65,
|
|
avg_rapport: 0.3,
|
|
trend: "up",
|
|
top_techniques: ["reflection", "open question"],
|
|
points,
|
|
},
|
|
points,
|
|
stage_breakdown: [
|
|
{ stage: "라포", sessions: 0, turns: 0 },
|
|
{ stage: "탐색", sessions: 1, turns: 34 },
|
|
{ stage: "개입", sessions: 1, turns: 42 },
|
|
{ stage: "정리", sessions: 0, turns: 0 },
|
|
],
|
|
sessions,
|
|
message: `${label} 전체 회기 ${sessions.length}건`,
|
|
};
|
|
}
|
|
|
|
async function fulfillJson(body: unknown) {
|
|
return {
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
async function routeEmptyConsole(page: Page, dashboard?: Record<string, unknown>) {
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(await fulfillJson(dashboardFixture(dashboard))),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
}
|
|
|
|
test.describe("professor console full-sweep", () => {
|
|
// checklist: professor-guard-pending-approval
|
|
test("redirects an unapproved teacher to /pending and returns an approved teacher from /pending", async ({
|
|
page,
|
|
}) => {
|
|
let accountStatus = "pending";
|
|
await page.route("**/api/auth/me", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(authMeFixture({ account_status: accountStatus })),
|
|
),
|
|
);
|
|
await routeEmptyConsole(page);
|
|
|
|
await page.goto("/teach");
|
|
await expect(page).toHaveURL(/\/pending$/);
|
|
await expect(page.getByText("승인 대기", { exact: true })).toBeVisible();
|
|
await expect(page.locator(".pf-root")).toHaveCount(0);
|
|
|
|
accountStatus = "approved";
|
|
await page.goto("/pending");
|
|
await expect(page).toHaveURL(/\/teach$/);
|
|
await expect(
|
|
page.getByRole("heading", { name: "검토할 실제 회기가 없습니다." }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// checklist: professor-guard-onboarding
|
|
test("redirects a teacher without onboarding from /teach to /onboarding", async ({
|
|
page,
|
|
}) => {
|
|
const res = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: `professor.sweep.onboarding.${Date.now()}@hs.ac.kr`,
|
|
role: "teacher",
|
|
display_name: "E2E Sweep Teacher",
|
|
},
|
|
});
|
|
expect(res.ok(), await res.text()).toBeTruthy();
|
|
|
|
await page.goto("/teach");
|
|
await expect(page).toHaveURL(/\/onboarding$/);
|
|
await expect(page.locator(".pf-root")).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: professor-dashboard-error-banner
|
|
test("shows a role=alert error banner when the dashboard load fails", async ({
|
|
page,
|
|
}) => {
|
|
await signInAsTeacher(page);
|
|
await page.route("**/api/teacher/dashboard", (route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "주입된 대시보드 오류" }),
|
|
}),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await page.goto("/teach");
|
|
const banner = page.locator("section.pf-error");
|
|
await expect(banner).toBeVisible();
|
|
await expect(banner).toHaveAttribute("role", "alert");
|
|
await expect(banner).toContainText("API 500");
|
|
await expect(banner).toContainText("주입된 대시보드 오류");
|
|
});
|
|
|
|
// checklist: professor-header-refresh
|
|
test("refresh button refetches dashboard and persona review queue and disables while refreshing", async ({
|
|
page,
|
|
}) => {
|
|
// React StrictMode(dev)는 마운트 effect를 두 번 실행하므로 초기 호출 수는
|
|
// 정확히 1이 아닐 수 있다. 절대 카운트 대신 "새로고침 클릭이 추가 호출을
|
|
// 발생시킨다"를 기준선 대비 증가로 검증한다.
|
|
let dashboardCalls = 0;
|
|
let reviewCalls = 0;
|
|
let holdDashboard = false;
|
|
const dashboardGate = deferred();
|
|
|
|
await page.route("**/api/teacher/dashboard", async (route) => {
|
|
dashboardCalls += 1;
|
|
if (holdDashboard) await dashboardGate.promise;
|
|
await route.fulfill(await fulfillJson(dashboardFixture()));
|
|
});
|
|
await page.route("**/api/personas/review", async (route) => {
|
|
reviewCalls += 1;
|
|
await route.fulfill(await fulfillJson([]));
|
|
});
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach");
|
|
|
|
const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ });
|
|
await expect(refreshButton).toHaveText(/새로고침/);
|
|
await expect(refreshButton).toBeEnabled();
|
|
const baselineDashboard = dashboardCalls;
|
|
const baselineReview = reviewCalls;
|
|
|
|
holdDashboard = true;
|
|
await refreshButton.click();
|
|
await expect(refreshButton).toHaveText(/확인 중/);
|
|
await expect(refreshButton).toBeDisabled();
|
|
|
|
holdDashboard = false;
|
|
dashboardGate.resolve();
|
|
await expect(refreshButton).toHaveText(/새로고침/);
|
|
await expect(refreshButton).toBeEnabled();
|
|
await expect.poll(() => dashboardCalls).toBe(baselineDashboard + 1);
|
|
await expect.poll(() => reviewCalls).toBe(baselineReview + 1);
|
|
});
|
|
|
|
// checklist: professor-kpi-cards
|
|
test("renders six KPI cards summarizing queue, alerts, sessions, and learners", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: 5,
|
|
active_sessions: 4,
|
|
ended_sessions: 6,
|
|
pending_reviews: [pendingReviewFixture(1)],
|
|
safety_alerts: [safetyAlertFixture(1), safetyAlertFixture(2)],
|
|
recent_sessions: [1, 2, 3].map(recentSessionFixture),
|
|
message: "1건의 리뷰가 대기 중입니다.",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([reviewPersonaFixture()])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach");
|
|
|
|
const kpis = page.locator(".pf-kpi");
|
|
await expect(kpis).toHaveCount(6);
|
|
const expected: Array<[string, string]> = [
|
|
["검토 대기", "1"],
|
|
["위기 알림", "2"],
|
|
["페르소나 검수", "1"],
|
|
["최근 세션", "3"],
|
|
["전체 학생", "5"],
|
|
["활성 세션", "4"],
|
|
];
|
|
for (const [label, value] of expected) {
|
|
const card = kpis.filter({ has: page.getByText(label, { exact: true }) });
|
|
await expect(card).toHaveCount(1);
|
|
await expect(card.locator("b")).toHaveText(value);
|
|
}
|
|
});
|
|
|
|
// checklist: professor-persona-review-error-retry
|
|
test("shows persona review error card with a retry button that reloads the queue", async ({
|
|
page,
|
|
}) => {
|
|
// StrictMode 이중 마운트로 초기 호출이 2회일 수 있으므로 호출 횟수가 아닌
|
|
// 플래그로 실패/복구를 전환한다.
|
|
let failReviews = true;
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(await fulfillJson(dashboardFixture())),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) => {
|
|
if (failReviews) {
|
|
return route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "review queue exploded" }),
|
|
});
|
|
}
|
|
return route.fulfill(await fulfillJson([reviewPersonaFixture()]));
|
|
});
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach");
|
|
|
|
const errorCard = page
|
|
.locator(".pf-section--persona")
|
|
.locator('[role="alert"]');
|
|
await expect(errorCard).toBeVisible();
|
|
await expect(errorCard).toContainText("검수 데이터를 불러오지 못했습니다");
|
|
|
|
failReviews = false;
|
|
await errorCard.getByRole("button", { name: "다시 시도" }).click();
|
|
await expect(
|
|
page.locator('[data-persona-review-row="true"]').filter({
|
|
hasText: "전수 검수 대기 페르소나",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(errorCard).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: professor-persona-review-empty-loading
|
|
test("shows persona review loading state, then the empty state", async ({
|
|
page,
|
|
}) => {
|
|
const firstReview = deferred();
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(await fulfillJson(dashboardFixture())),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) => {
|
|
await firstReview.promise;
|
|
await route.fulfill(await fulfillJson([]));
|
|
});
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach");
|
|
|
|
await expect(page.getByText("페르소나 검수 큐 확인 중")).toBeVisible();
|
|
firstReview.resolve();
|
|
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
|
|
await expect(page.getByText("페르소나 검수 큐 확인 중")).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: professor-analysis-sort-segment
|
|
test("re-sorts the learner table by latest, sessions, score, and delta", async ({
|
|
page,
|
|
}) => {
|
|
const growth = [
|
|
growthLearnerFixture("sweep-learner-a", "학습자 A", {
|
|
sessions: 5,
|
|
latest_at: "2026-07-01T09:00:00Z",
|
|
latest_score: 0.4,
|
|
score_delta: 0.1,
|
|
}),
|
|
growthLearnerFixture("sweep-learner-b", "학습자 B", {
|
|
sessions: 2,
|
|
latest_at: "2026-07-10T09:00:00Z",
|
|
latest_score: 0.9,
|
|
score_delta: -0.2,
|
|
}),
|
|
growthLearnerFixture("sweep-learner-c", "학습자 C", {
|
|
sessions: 8,
|
|
latest_at: "2026-07-05T09:00:00Z",
|
|
latest_score: 0.6,
|
|
score_delta: 0.5,
|
|
}),
|
|
];
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({ total_learners: 3, learner_growth: growth }),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach/analysis");
|
|
|
|
const segment = page.locator(".pf-sort-segment");
|
|
await expect(segment).toBeVisible();
|
|
const names = page.locator(
|
|
'[data-learner-overview-row="true"] .pf-learner-name-button',
|
|
);
|
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
|
|
|
await segment.getByRole("button", { name: "회기순" }).click();
|
|
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
|
|
|
|
await segment.getByRole("button", { name: "점수순" }).click();
|
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
|
|
|
await segment.getByRole("button", { name: "변화순" }).click();
|
|
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
|
|
|
|
await segment.getByRole("button", { name: "최근순" }).click();
|
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
|
});
|
|
|
|
// checklist: professor-analysis-table-states
|
|
test("distinguishes learner table loading, no-search-result, and empty states", async ({
|
|
page,
|
|
}) => {
|
|
// StrictMode 이중 마운트에서도 로딩 상태가 관측되도록, 게이트 해제 전까지
|
|
// 들어오는 모든 dashboard 요청을 잡아둔다.
|
|
const firstDashboard = deferred();
|
|
let holdDashboard = true;
|
|
let growth: unknown[] = [
|
|
growthLearnerFixture("sweep-learner-states", "상태 학습자"),
|
|
];
|
|
await page.route("**/api/teacher/dashboard", async (route) => {
|
|
if (holdDashboard) await firstDashboard.promise;
|
|
await route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: growth.length,
|
|
learner_growth: growth,
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach/analysis");
|
|
|
|
// 1) 로딩 상태
|
|
await expect(page.getByText("학습자 추이 계산 중")).toBeVisible();
|
|
firstDashboard.resolve();
|
|
await expect(
|
|
page.locator('[data-learner-overview-table="true"]'),
|
|
).toBeVisible();
|
|
|
|
// 2) 검색 결과 없음
|
|
await page.getByLabel("학습자 검색").fill("존재하지-않는-학습자-zzz");
|
|
await expect(page.getByText("검색 결과 없음")).toBeVisible();
|
|
|
|
// 3) 데이터 자체가 없는 빈 상태
|
|
growth = [];
|
|
await page.getByRole("button", { name: "새로고침" }).click();
|
|
await expect(page.getByText("표시할 학습자 이력 없음")).toBeVisible();
|
|
});
|
|
|
|
// checklist: professor-analysis-back-to-list
|
|
test("returns from learner detail to the full list via 전체 목록", async ({
|
|
page,
|
|
}) => {
|
|
const learnerId = "sweep-learner-back";
|
|
const analysis = learnerAnalysisFixture(learnerId, "복귀 학습자");
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: 1,
|
|
learner_growth: [
|
|
growthLearnerFixture(learnerId, "복귀 학습자", {
|
|
points: analysis.points,
|
|
}),
|
|
],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
|
route.fulfill(await fulfillJson(analysis)),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
|
|
|
await expect(
|
|
page.locator('[data-learner-analysis-panel="true"]'),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "전체 목록" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
|
await expect(
|
|
page.locator('[data-learner-overview-table="true"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-learner-analysis-panel="true"]'),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
// checklist: professor-analysis-invalid-learner-redirect
|
|
test("clears an unknown learner query and falls back to the list", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: 1,
|
|
learner_growth: [
|
|
growthLearnerFixture("sweep-learner-known", "실존 학습자"),
|
|
],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/teacher/learners/*/analysis", (route) =>
|
|
route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "learner not found" }),
|
|
}),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach/analysis?learner=sweep-learner-missing");
|
|
|
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
|
await expect(
|
|
page.locator('[data-learner-overview-table="true"]'),
|
|
).toBeVisible();
|
|
await expect(page.getByText("실존 학습자")).toBeVisible();
|
|
});
|
|
|
|
// checklist: professor-analysis-trend-chart
|
|
test("renders the trend tab bar chart with per-session tooltips", async ({
|
|
page,
|
|
}) => {
|
|
const learnerId = "sweep-learner-trend";
|
|
const analysis = learnerAnalysisFixture(learnerId, "추이 학습자");
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: 1,
|
|
learner_growth: [
|
|
growthLearnerFixture(learnerId, "추이 학습자", {
|
|
points: analysis.points,
|
|
}),
|
|
],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
|
route.fulfill(await fulfillJson(analysis)),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
|
|
|
const panel = page.locator('[data-learner-analysis-panel="true"]');
|
|
await expect(panel).toBeVisible();
|
|
await panel.getByRole("tab", { name: /^추이/ }).click();
|
|
|
|
const bars = panel.locator(".pf-analysis-bars .pf-growth-bar");
|
|
await expect(bars).toHaveCount(2);
|
|
await expect(bars.nth(0)).toHaveAttribute("title", "P1 1회기 · 50%");
|
|
await expect(bars.nth(1)).toHaveAttribute("title", "P1 2회기 · 80%");
|
|
});
|
|
|
|
// checklist: professor-analysis-virtualized-table (결정 D4 — 학생 분석 테이블 가상화)
|
|
test("virtualizes the 300-learner table while keeping scroll, search, and expand", async ({
|
|
page,
|
|
}) => {
|
|
// 최근순 정렬이 001→300 순서가 되도록 latest_at을 1분 간격 내림차순으로 부여한다.
|
|
const growth = Array.from({ length: 300 }, (_, index) => {
|
|
const no = String(index + 1).padStart(3, "0");
|
|
const latestAt = new Date(
|
|
Date.parse("2026-07-20T09:00:00Z") - index * 60_000,
|
|
).toISOString();
|
|
return growthLearnerFixture(`sweep-virtual-${no}`, `가상화 학습자 ${no}`, {
|
|
latest_at: latestAt,
|
|
});
|
|
});
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: growth.length,
|
|
learner_growth: growth,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto("/teach/analysis");
|
|
|
|
const table = page.locator('[data-learner-overview-table="true"]');
|
|
await expect(table).toBeVisible();
|
|
const rows = page.locator('[data-learner-overview-row="true"]');
|
|
await expect(
|
|
rows
|
|
.first()
|
|
.getByRole("button", { name: "가상화 학습자 001", exact: true }),
|
|
).toBeVisible();
|
|
|
|
// (a) 가상화: 300명 전부가 아니라 화면 주변 행만 DOM에 렌더된다.
|
|
const renderedRows = await rows.count();
|
|
expect(renderedRows).toBeGreaterThan(0);
|
|
expect(renderedRows).toBeLessThan(60);
|
|
|
|
// (b) 스크롤 컨테이너를 끝까지 내리면 마지막 학습자 행이 나타난다.
|
|
// 동적 행 높이 측정으로 scrollHeight가 갱신되므로 나타날 때까지 반복 스크롤한다.
|
|
await expect
|
|
.poll(async () => {
|
|
await table.evaluate((el) => {
|
|
el.scrollTop = el.scrollHeight;
|
|
});
|
|
return rows
|
|
.getByRole("button", { name: "가상화 학습자 300", exact: true })
|
|
.count();
|
|
})
|
|
.toBeGreaterThan(0);
|
|
await expect(
|
|
rows.getByRole("button", { name: "가상화 학습자 300", exact: true }),
|
|
).toBeVisible();
|
|
|
|
// (c) 검색: 상위권 밖 이름을 치면 해당 행이 표시된다.
|
|
await page.getByLabel("학습자 검색").fill("가상화 학습자 250");
|
|
const filteredRow = rows.filter({ hasText: "가상화 학습자 250" });
|
|
await expect(filteredRow).toHaveCount(1);
|
|
|
|
// (d) 행 펼침 후 상세가 보인다.
|
|
await filteredRow
|
|
.getByRole("button", { name: "가상화 학습자 250 요약 펼치기" })
|
|
.click();
|
|
const expanded = page.locator('[data-learner-expanded-row="true"]');
|
|
await expect(expanded).toBeVisible();
|
|
await expect(expanded.getByText("회기별 추이")).toBeVisible();
|
|
});
|
|
|
|
// checklist: professor-analysis-stage-breakdown
|
|
test("renders stage breakdown cards with session and turn counts", async ({
|
|
page,
|
|
}) => {
|
|
const learnerId = "sweep-learner-stages";
|
|
const analysis = learnerAnalysisFixture(learnerId, "단계 학습자");
|
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
|
route.fulfill(
|
|
await fulfillJson(
|
|
dashboardFixture({
|
|
total_learners: 1,
|
|
learner_growth: [
|
|
growthLearnerFixture(learnerId, "단계 학습자", {
|
|
points: analysis.points,
|
|
}),
|
|
],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
|
route.fulfill(await fulfillJson(analysis)),
|
|
);
|
|
await page.route("**/api/personas/review", async (route) =>
|
|
route.fulfill(await fulfillJson([])),
|
|
);
|
|
|
|
await signInAsTeacher(page);
|
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
|
|
|
const panel = page.locator('[data-learner-analysis-panel="true"]');
|
|
await expect(panel).toBeVisible();
|
|
await panel.getByRole("tab", { name: /^단계 분석/ }).click();
|
|
|
|
const stageItems = panel.locator(".pf-stage-item");
|
|
await expect(stageItems).toHaveCount(4);
|
|
const explore = stageItems.filter({ hasText: "탐색" });
|
|
await expect(explore.locator("b")).toHaveText("1");
|
|
await expect(explore.locator("small")).toHaveText("34턴");
|
|
const intervene = stageItems.filter({ hasText: "개입" });
|
|
await expect(intervene.locator("small")).toHaveText("42턴");
|
|
await expect(stageItems.filter({ hasText: "라포" })).toHaveCount(1);
|
|
await expect(stageItems.filter({ hasText: "정리" })).toHaveCount(1);
|
|
});
|
|
});
|