import { expect, test, type Page, type Response } from "@playwright/test"; import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner, signInAsTeacher, } from "./support"; interface SessionStartResponse { session_id: string; } async function expectResponseOk(response: { ok: () => boolean; text: () => Promise }) { if (!response.ok()) { expect(response.ok(), await response.text()).toBeTruthy(); } } async function expectVisibleButtonsFit(page: Page, selector: string, context: string) { const clippedButtons = await page.locator(selector).evaluateAll((buttons) => buttons .map((button) => { const rect = button.getBoundingClientRect(); const owner = button.closest(".pf-persona,.pf-panel") ?? button.parentElement; const ownerRect = owner?.getBoundingClientRect(); const style = window.getComputedStyle(button); const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; const contentClipped = button.scrollWidth > button.clientWidth + 1 || button.scrollHeight > button.clientHeight + 1; const outsideOwner = ownerRect ? rect.left < ownerRect.left - 1 || rect.right > ownerRect.right + 1 || rect.top < ownerRect.top - 1 || rect.bottom > ownerRect.bottom + 1 : false; return { text: button.textContent?.replace(/\s+/g, " ").trim(), width: Math.ceil(rect.width), contentClipped, outsideOwner, visible, }; }) .filter((button) => button.visible && (button.contentClipped || button.outsideOwner)), ); expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]); } function isTeacherDashboardResponse(response: Response) { const url = new URL(response.url()); return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard"); } async function createEndedLearnerSession(page: Page) { await signInAsLearner(page); const persona = await fetchAvailablePersona(page); const start = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic", }, }); await expectResponseOk(start); const session = (await start.json()) as SessionStartResponse; const ended = await page.request.post(`/api/sessions/${session.session_id}/end`); await expectResponseOk(ended); return session.session_id; } function recentSessionFixture(index: number) { const padded = String(index).padStart(2, "0"); return { session_id: `mobile-readable-session-${padded}-00000000-0000-4000-9000-${padded}${padded}${padded}${padded}${padded}${padded}`, learner_id: `learner-${padded}`, learner_label: `E2E Learner ${padded}`, persona_code: `P-MOBILE-${padded}`, persona_name: `Responsive persona ${padded}`, session_no: index, status: index % 2 === 0 ? "active" : "ended", stage: index % 2 === 0 ? "intervention-planning" : "rapport-and-assessment", turn_count: 12 + index, learner_turn_count: 6 + index, client_turn_count: 6, started_at: `2026-06-26T0${index}:10:00Z`, ended_at: index % 2 === 0 ? null : `2026-06-26T0${index}:45:00Z`, }; } function teacherReviewResponse(sessionId: string) { return { session_id: sessionId, client: { name: "서연", initial: "서", persona: "P1 · 고난도", }, date: "2026-06-27", durationLabel: "32분", durationSeconds: 1920, reachedPhase: "정리", sessionSignal: "종료됨", supervisorState: "평가 완료", supervisorName: "AI", teacherReview: { status: "pending", note: "", reviewerId: null, reviewedAt: null, updatedAt: null, }, summary: "교수자가 검토할 수 있는 종료 회기 리뷰입니다.", phases: [{ key: "closing", label: "정리", weight: 1 }], phaseAxis: ["0:00", "32:00"], valenceAxis: [], clientValence: [], counselorBaseline: [], turns: [ { id: "t1", ts: "01:00", speaker: "learner", who: "학습자", text: "오늘은 비교당할 때의 감정을 더 살펴보고 싶습니다.", techniques: [{ kind: "explore", label: "탐색 질문" }], nonverbal: [], note: { author: "평가 AI", tone: "good", title: "검토 가능", body: "교수자가 읽을 수 있는 자동 평가 노트입니다.", quote: null, }, }, { id: "t2", ts: "02:10", speaker: "client", who: "서연", text: "말하기는 어렵지만 계속 비교당하는 게 힘들어요.", techniques: [], nonverbal: [], note: null, }, ], rubric: [], goodMoments: [], growthPoints: [], caseWorksheet: { status: "draft_from_transcript", generatedBy: "rule-based transcript extractor", sections: [ { key: "trigger", title: "촉발 장면", items: [ { key: "comparison", label: "비교 경험", value: "반복 비교 상황에서 감정 탐색이 필요함.", evidence: [{ turnId: "t2", speaker: "client", quote: "계속 비교당하는 게 힘들어요" }], confidence: "medium", emptyReason: null, }, ], }, ], limitations: ["교수자 검토 화면에서는 학습자 제출물을 수정하지 않습니다."], savedAt: null, }, nextLine: null, clientFeedback: null, audioUrl: null, pdfExportUrl: null, degraded: false, reviewReady: true, }; } test.describe("teacher console", () => { test("lets a teacher approve a pending persona review from the console @single-run", async ({ page, }) => { await signInAsTeacher(page); const personaId = "00000000-0000-0000-0000-000000009901"; const pendingPersona = { persona_id: personaId, code: "P2", version: 3, status: "review", display_name: "검수 대기 페르소나", difficulty: "moderate", theory_target: ["humanistic"], source_provenance: "faculty import", is_synthetic: true, created_at: "2026-06-26T07:00:00Z", approved_at: null, }; await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: 0, active_sessions: 0, ended_sessions: 0, pending_reviews: [], recent_sessions: [], message: "검토할 실제 회기가 없습니다.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([pendingPersona]), }), ); await page.route(`**/api/personas/review/${personaId}`, async (route) => { expect(route.request().method()).toBe("POST"); expect(route.request().postDataJSON()).toEqual({ action: "approve" }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...pendingPersona, status: "approved", approved_at: "2026-06-26T07:01:00Z", }), }); }); await page.goto("/teach"); const row = page.locator('[data-persona-review-row="true"]').filter({ hasText: "검수 대기 페르소나", }); await expect(row).toBeVisible(); await expect(row.getByText("검수 대기", { exact: true })).toBeVisible(); await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "persona review actions"); await row.getByRole("button", { name: "승인" }).click(); await expect(row).toHaveCount(0); await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible(); await expectNoHorizontalOverflow(page); }); test("lets a teacher revise an approved persona from persona studio @single-run", async ({ page }) => { const personaId = "00000000-0000-0000-0000-000000000701"; let revisionRequested = false; const approvedPersona = { persona_id: personaId, code: "P1", version: 1, status: "approved", display_name: "서연(가명) · 고2 · 우울/자살사고", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "F-teen" }, presenting_summary: "최근 무기력과 자살사고를 호소합니다.", source: "database", degraded: false, voice_preset: null, }; const draftDetail = { ...approvedPersona, version: 2, status: "draft", source_provenance: "bootstrap seed migrated to editable catalog", is_synthetic: true, created_at: "2026-06-28T00:00:00Z", approved_at: null, presenting: { complaint: "최근 무기력과 자살사고를 호소합니다." }, history: { family: "가족 갈등" }, big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 }, resistance: { base_resistance: 0.6, unlock_rate: 0.1, decay_floor: 0.05 }, speech_style: { register: "polite", honorific: true }, affect_baseline: { negative_affect: 0.7, anxiety: 0.4, suicide_ideation_stage: 1 }, ccd: { core_belief: "나는 짐이 된다" }, dsm5_dimensional: { depression: "moderate" }, triggers: { sore_spots: ["평가절하"], forbidden: ["단정"] }, }; await signInAsTeacher(page); await page.route("**/api/personas", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([approvedPersona]), }); }); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }), ); await page.route(`**/api/personas/${personaId}/revisions`, async (route) => { revisionRequested = true; expect(route.request().method()).toBe("POST"); expect(route.request().postDataJSON()).toEqual({ submit_for_review: false }); await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(draftDetail), }); }); await page.goto("/teach/personas"); const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" }); await expect(row).toBeVisible(); await row.getByRole("button", { name: "수정" }).click(); await expect(page.getByText("P1 v2 수정 초안을 불러왔습니다.")).toBeVisible(); await expect(page.getByLabel("표시 이름")).toHaveValue("서연(가명) · 고2 · 우울/자살사고"); await expect(page.getByLabel("코드")).toHaveValue("P1"); expect(revisionRequested).toBeTruthy(); await expectNoHorizontalOverflow(page); }); test("archives an approved persona from persona studio without layout drift @single-run", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const personaId = "00000000-0000-0000-0000-000000000702"; let archived = false; const approvedPersona = { persona_id: personaId, code: "P7", version: 1, status: "approved", display_name: "도현(가명) · 고3 · 입시 번아웃/무기력", difficulty: "moderate", theory_target: ["humanistic"], demographics: { age_band: "M-teen" }, presenting_summary: "입시 번아웃과 무기력", source: "database", degraded: false, voice_preset: null, }; await signInAsTeacher(page); await page.route("**/api/personas", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(archived ? [] : [approvedPersona]), }); }); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }), ); await page.route(`**/api/personas/${personaId}`, async (route) => { archived = true; expect(route.request().method()).toBe("DELETE"); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ persona_id: personaId, code: "P7", version: 1, status: "archived", display_name: approvedPersona.display_name, difficulty: approvedPersona.difficulty, theory_target: approvedPersona.theory_target, source_provenance: "bootstrap seed migrated to editable catalog", is_synthetic: true, created_at: "2026-06-28T00:00:00Z", approved_at: null, }), }); }); await page.goto("/teach/personas"); const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P7" }); await expect(row).toBeVisible(); await expectVisibleButtonsFit(page, ".ps-approved-row__actions .vg-btn", "approved persona actions"); page.once("dialog", (dialog) => dialog.accept()); await row.getByRole("button", { name: "삭제" }).click(); await expect(page.getByText("P7 페르소나를 보관 처리했습니다.")).toBeVisible(); await expect(page.getByText("공개 목록 없음")).toBeVisible(); expect(archived).toBeTruthy(); await expectNoHorizontalOverflow(page); }); test("renders real server sessions from server-owned rows", async ({ page }) => { const sessionId = await createEndedLearnerSession(page); await signInAsTeacher(page); const dashboardResponsePromise = page.waitForResponse(isTeacherDashboardResponse); await page.goto("/teach"); const dashboardResponse = await dashboardResponsePromise; await expectResponseOk(dashboardResponse); const dashboard = await dashboardResponse.json(); expect(dashboard.recent_sessions.some((session: { session_id: string }) => session.session_id === sessionId)).toBe( true, ); await expect(page.locator("code").filter({ hasText: sessionId }).first()).toBeVisible(); await expect(page.getByRole("heading", { name: /\d+건의 리뷰가 대기 중입니다\./ })).toBeVisible(); await expect(page.getByText("3명에게 개입")).toHaveCount(0); await expect(page.getByText("김상담")).toHaveCount(0); await expectNoHorizontalOverflow(page); }); test("opens a pending session review from the teacher queue @single-run", async ({ page }) => { const sessionId = "teacher-review-fixture-session"; await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: 1, active_sessions: 0, ended_sessions: 1, learner_growth: [], safety_alerts: [], pending_reviews: [ { session_id: sessionId, learner_id: "00000000-0000-0000-0000-000000000111", learner_label: "E2E Learner", persona_code: "P1", persona_name: "서연", session_no: 1, status: "ended", stage: "정리", turn_count: 6, learner_turn_count: 3, client_turn_count: 3, started_at: "2026-06-27T07:00:00Z", ended_at: "2026-06-27T07:32:00Z", review_status: "pending", review_note: null, reviewed_at: null, }, ], recent_sessions: [], message: "교수자 검토 대기 회기가 있습니다.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "[]", }), ); await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(teacherReviewResponse(sessionId)), }), ); await page.route(`**/api/teacher/sessions/${sessionId}/review-status`, async (route) => { const body = route.request().postDataJSON() as { status: string; note: string }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, status: body.status, note: body.note, reviewer_id: "00000000-0000-0000-0000-000000000901", reviewed_at: body.status === "closed" ? "2026-06-27T10:00:00Z" : null, updated_at: "2026-06-27T10:00:00Z", }), }); }); await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator(".pf-triage")).toBeVisible(); await expect(page.locator(".pf-triage")).toContainText("리뷰 대기"); const row = page.getByRole("button", { name: /E2E Learner P1 회기 상세 검토/ }); await expect(row).toBeVisible(); await row.click(); await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`)); await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible(); await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible(); await expect(page.getByText("축어록 자동 초안 읽기 전용")).toBeVisible(); await expect(page.getByText("검토 전용")).toBeVisible(); await page.getByLabel("검토 메모").fill("다음 회기에서 감정 반영을 먼저 확인"); await page.getByRole("button", { name: "검토 완료" }).click(); await expect(page.getByText("완료 시각 2026-06-27T10:00:00Z")).toBeVisible(); await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled(); await expect(page.getByRole("button", { name: "교수 콘솔로" })).toBeVisible(); await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0); await expectNoHorizontalOverflow(page); }); test("opens recent ended and active sessions from the teacher history @single-run", async ({ page, }) => { const endedSession = { ...recentSessionFixture(1), session_id: "teacher-recent-ended-session", learner_label: "Recent Ended Learner", persona_code: "P1", status: "ended", ended_at: "2026-06-27T07:32:00Z", }; const activeSession = { ...recentSessionFixture(2), session_id: "teacher-recent-active-session", learner_label: "Recent Active Learner", persona_code: "P2", status: "active", ended_at: null, }; await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: 2, active_sessions: 1, ended_sessions: 1, learner_growth: [], safety_alerts: [], pending_reviews: [], recent_sessions: [endedSession, activeSession], message: "최근 회기 기록 fixture.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "[]", }), ); await page.route(`**/api/sessions/${endedSession.session_id}/review`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(teacherReviewResponse(endedSession.session_id)), }), ); await page.route(`**/api/sessions/${activeSession.session_id}/review`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...teacherReviewResponse(activeSession.session_id), sessionSignal: "진행 중", supervisorState: "평가 대기", summary: "진행 중인 세션의 현재 기록을 교수자가 읽기 전용으로 확인합니다.", reviewReady: false, degraded: true, }), }), ); await signInAsTeacher(page); await page.goto("/teach"); const endedRow = page.getByRole("button", { name: /Recent Ended Learner P1 상세 리뷰/, }); await expect(endedRow).toBeVisible(); await endedRow.click(); await expect(page).toHaveURL(new RegExp(`/teach/session/${endedSession.session_id}/review$`)); await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible(); await expect(page.getByText("종료됨", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "교수 콘솔로" }).click(); const activeRow = page.getByRole("button", { name: /Recent Active Learner P2 진행 기록/, }); await expect(activeRow).toBeVisible(); await activeRow.click(); await expect(page).toHaveURL(new RegExp(`/teach/session/${activeSession.session_id}/review$`)); await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible(); await expect(page.getByText("진행 중", { exact: true })).toBeVisible(); await expect(page.getByText("평가 대기", { exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); }); test("opens a recent session review from the teacher history table @single-run", async ({ page }) => { const sessionId = "teacher-recent-fixture-session"; await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: 1, active_sessions: 1, ended_sessions: 0, learner_growth: [], safety_alerts: [], pending_reviews: [], recent_sessions: [ { session_id: sessionId, learner_id: "00000000-0000-0000-0000-000000000222", learner_label: "Recent Learner", persona_code: "P2", persona_name: "민재", session_no: 2, status: "active", stage: "탐색", turn_count: 4, learner_turn_count: 2, client_turn_count: 2, started_at: "2026-06-27T08:00:00Z", ended_at: null, }, ], message: "최근 진행 회기가 있습니다.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "[]", }), ); const review = teacherReviewResponse(sessionId); review.sessionSignal = "진행 중"; review.supervisorState = "평가 대기"; review.reviewReady = false; await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(review), }), ); await signInAsTeacher(page); await page.goto("/teach"); const row = page.getByRole("button", { name: /Recent Learner P2 진행 기록/ }); await expect(row).toBeVisible(); await row.press("Enter"); await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`)); await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible(); await expect(page.getByText("진행 중", { exact: true })).toBeVisible(); await expect(page.getByText("검토 전용")).toBeVisible(); await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0); await expectNoHorizontalOverflow(page); }); test("keeps recent sessions readable without horizontal scrolling across breakpoints", async ({ page, }) => { const recentSessions = [1, 2, 3].map(recentSessionFixture); await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: recentSessions.length, active_sessions: 1, ended_sessions: 2, pending_reviews: [], recent_sessions: recentSessions, message: "Fixture-backed recent session layout check.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "[]", }), ); await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator(".pf-recent-list")).toBeVisible(); await expect(page.locator('[data-recent-session-row="true"]')).toHaveCount( recentSessions.length, ); await expectNoHorizontalOverflow(page); const metrics = await page.evaluate(() => { const doc = document.documentElement; const list = document.querySelector(".pf-recent-list"); const header = document.querySelector(".pf-recent-head"); const row = document.querySelector('[data-recent-session-row="true"]'); const cells = Array.from(row?.querySelectorAll(".pf-recent__cell") ?? []); if (!list || !header || !row || cells.length === 0) { throw new Error("recent sessions list was not rendered"); } const listRect = list.getBoundingClientRect(); const rowRect = row.getBoundingClientRect(); return { viewportWidth: doc.clientWidth, listOverflowX: Math.ceil(list.scrollWidth - list.clientWidth), headerDisplay: window.getComputedStyle(header).display, headerPosition: window.getComputedStyle(header).position, rowDisplay: window.getComputedStyle(row).display, gridCellCount: cells.filter((cell) => window.getComputedStyle(cell).display === "grid").length, labels: cells.map((cell) => cell.getAttribute("data-label")), rowRight: Math.ceil(rowRect.right), listRight: Math.ceil(listRect.right), }; }); expect(metrics.listOverflowX).toBeLessThanOrEqual(1); expect(metrics.rowRight).toBeLessThanOrEqual(metrics.listRight + 1); if (metrics.viewportWidth <= 860) { expect(metrics.headerDisplay).toBe("none"); expect(metrics.rowDisplay).toBe("grid"); expect(metrics.gridCellCount).toBe(7); expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료", "열기"]); } else { expect(metrics.headerDisplay).toBe("grid"); expect(metrics.headerPosition).toBe("sticky"); expect(metrics.rowDisplay).toBe("grid"); expect(metrics.gridCellCount).toBe(0); } }); test("keeps long teacher lists in bounded panels", async ({ page }) => { await createEndedLearnerSession(page); await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator(".pf-list")).toBeVisible(); await expect(page.locator(".pf-recent-list")).toBeVisible(); const metrics = await page.evaluate(() => { const list = document.querySelector(".pf-list"); const recent = document.querySelector(".pf-recent-list"); const header = document.querySelector(".pf-recent-head"); if (!list || !recent || !header) { throw new Error("teacher list panels were not rendered"); } const listStyle = window.getComputedStyle(list); const recentStyle = window.getComputedStyle(recent); const headerStyle = window.getComputedStyle(header); const doc = document.documentElement; return { docHeight: doc.scrollHeight, viewportHeight: doc.clientHeight, listMaxHeight: listStyle.maxHeight, listOverflowY: listStyle.overflowY, recentMaxHeight: recentStyle.maxHeight, recentOverflowY: recentStyle.overflowY, headerPosition: headerStyle.position, }; }); expect(metrics.listMaxHeight).not.toBe("none"); expect(metrics.recentMaxHeight).not.toBe("none"); expect(["auto", "scroll"]).toContain(metrics.listOverflowY); expect(["auto", "scroll"]).toContain(metrics.recentOverflowY); expect(metrics.headerPosition).toBe("sticky"); expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200); await expectNoHorizontalOverflow(page); }); test("keeps persona review actions contained on mobile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "database", cohort_label: "E2E cohort", total_learners: 1, active_sessions: 0, ended_sessions: 0, pending_reviews: [], recent_sessions: [], message: "Mobile review action layout check.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([ { persona_id: "00000000-0000-0000-0000-000000009902", code: "P-LONG-MOBILE", version: 11, status: "review", display_name: "아주 긴 이름의 모바일 검수 대상 페르소나", difficulty: "advanced", theory_target: ["humanistic", "cognitive-behavioral"], source_provenance: "mobile action clipping fixture", is_synthetic: true, created_at: "2026-06-26T07:00:00Z", approved_at: null, }, ]), }), ); await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator('[data-persona-review-row="true"]')).toBeVisible(); await expectNoHorizontalOverflow(page); await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "mobile persona review actions"); }); test("denies learner access to the teacher dashboard API and UI", async ({ page }) => { await signInAsLearner(page); const denied = await page.request.get("/api/teacher/dashboard"); expect(denied.status(), await denied.text()).toBe(403); await page.goto("/teach"); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".pf-root")).toHaveCount(0); }); });