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`, }; } 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("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("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(6); 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); }); });