/** * 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((res) => { resolve = res; }); return { promise, resolve }; } function authMeFixture(overrides: Record = {}) { 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 = {}) { 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 = {}) { 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 = {}, ) { 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) { 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 triageBox = await page.locator(".pf-triage").boundingBox(); const summaryBox = await page.getByLabel("교수자 요약").boundingBox(); expect(triageBox).not.toBeNull(); expect(summaryBox).not.toBeNull(); expect( Math.abs(summaryBox!.y - (triageBox!.y + triageBox!.height) - 12), "검토 큐와 교수자 요약 카드 사이 간격", ).toBeLessThanOrEqual(1); 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); }); });