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("saves persona studio list rows as structured arrays @single-run", async ({ page }) => { type PersonaDraftSavePayload = { ccd: { automatic_thought: string[] }; code: string; difficulty: string; display_name: string; is_synthetic: boolean; source_provenance: unknown; theory_target: string; triggers: { forbidden: string[]; sore_spots: string[] }; }; let savedPayload: PersonaDraftSavePayload | null = 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([]) }); }); await page.route("**/api/personas/review", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); }); await page.route("**/api/personas/drafts", async (route) => { if (route.request().method() !== "POST") return route.fallback(); savedPayload = route.request().postDataJSON() as PersonaDraftSavePayload; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ persona_id: "00000000-0000-0000-0000-000000000703", code: savedPayload.code, version: 1, status: "draft", display_name: savedPayload.display_name, difficulty: savedPayload.difficulty, theory_target: savedPayload.theory_target, source_provenance: savedPayload.source_provenance, is_synthetic: savedPayload.is_synthetic, created_at: "2026-06-28T00:00:00Z", approved_at: null, }), }); }); await page.goto("/teach/personas"); await page.getByLabel("표시 이름").fill("항목형 페르소나"); await page.getByRole("tab", { name: "임상" }).click(); const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" }); await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("삭제될 자동사고"); await automaticThoughts.getByRole("button", { name: "항목 추가" }).click(); await automaticThoughts.getByRole("textbox", { name: "자동사고 2", exact: true }).fill("남길 자동사고"); await automaticThoughts.getByRole("button", { name: "자동사고 1 삭제" }).click(); await page.getByRole("tab", { name: "안전" }).click(); const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" }); await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정"); await page.getByRole("button", { name: "초안 저장" }).click(); await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible(); expect(savedPayload).toBeTruthy(); expect(savedPayload?.ccd.automatic_thought).toEqual(["남길 자동사고"]); expect(savedPayload?.triggers.forbidden).toEqual(["네가 예민한 거라고 단정"]); expect(savedPayload?.triggers.sore_spots).toEqual([]); await expectNoHorizontalOverflow(page); }); test("renders persona prompt preview as labeled sections without raw JSON @single-run", async ({ page }) => { 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([]) }); }); await page.route("**/api/personas/review", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); }); await page.goto("/teach/personas"); await page.getByLabel("표시 이름").fill("프롬프트 검토 페르소나"); await page.getByRole("tab", { name: "임상" }).click(); const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" }); await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("말하면 더 이상하게 볼 거야"); await page.getByRole("tab", { name: "안전" }).click(); const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" }); await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정"); await page.getByRole("tab", { name: "프롬프트" }).click(); const promptPreview = page.getByLabel("프롬프트 미리보기"); await expect(promptPreview.getByRole("heading", { name: /L1 페르소나 카드/ })).toBeVisible(); await expect(promptPreview.getByText("자동사고")).toBeVisible(); await expect(promptPreview.getByText("말하면 더 이상하게 볼 거야")).toBeVisible(); await expect(promptPreview.getByText("상담자 금기")).toBeVisible(); await expect(promptPreview.getByText("네가 예민한 거라고 단정")).toBeVisible(); await expect(promptPreview.getByRole("textbox")).toHaveCount(0); await expect(promptPreview).not.toContainText('"automatic_thought"'); await expect(promptPreview).not.toContainText('"forbidden"'); await expect(promptPreview).not.toContainText("{"); 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("shows selected learner analysis with the full session timeline @single-run", async ({ page, }) => { const learnerA = "teacher-analysis-learner-a"; const learnerB = "teacher-analysis-learner-b"; const sessionFor = (learnerId: string, label: string, index: number) => ({ session_id: `${learnerId}-session-${index}`, learner_id: learnerId, learner_label: label, persona_code: index % 2 === 0 ? "P2" : "P1", persona_name: index % 2 === 0 ? "민재" : "서연", session_no: index, status: "ended", stage: index > 4 ? "개입" : "탐색", turn_count: 8 + index, learner_turn_count: 4 + index, client_turn_count: 4, started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`, ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`, review_status: index === 2 ? "closed" : "pending", review_note: null, reviewed_at: index === 2 ? "2026-06-12T10:00:00Z" : null, }); const pointFor = (learnerId: string, index: number) => ({ session_id: `${learnerId}-session-${index}`, session_no: index, persona_code: index % 2 === 0 ? "P2" : "P1", stage: index > 4 ? "개입" : "탐색", started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`, ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`, score: index >= 5 ? 1 : 0.5, rapport: Math.min(0.8, index * 0.1), technique_count: index + 1, watch_count: index < 5 ? 1 : 0, }); const analysisFor = (learnerId: string, label: string, count: number) => { const sessions = Array.from({ length: count }, (_unused, idx) => sessionFor(learnerId, label, idx + 1), ); const points = Array.from({ length: count }, (_unused, idx) => pointFor(learnerId, idx + 1), ); return { source: "database", learner_id: learnerId, learner_label: label, total_sessions: count, active_sessions: 0, ended_sessions: count, pending_reviews: Math.max(0, count - 1), closed_reviews: count > 1 ? 1 : 0, summary: { learner_id: learnerId, learner_label: label, sessions: count, ended_sessions: count, latest_at: sessions[count - 1].ended_at ?? "", first_score: points[0].score, latest_score: points[count - 1].score, score_delta: (points[count - 1].score ?? 0) - (points[0].score ?? 0), avg_score: 0.75, avg_rapport: 0.4, trend: count > 3 ? "up" : "flat", top_techniques: ["reflection", "open question"], points, }, points, stage_breakdown: [ { stage: "라포", sessions: 0, turns: 0 }, { stage: "탐색", sessions: Math.min(4, count), turns: 34 }, { stage: "개입", sessions: Math.max(0, count - 4), turns: 42 }, { stage: "정리", sessions: 0, turns: 0 }, ], sessions, message: `${label} 전체 회기 ${count}건`, }; }; const analysisA = analysisFor(learnerA, "사용자 A", 7); const analysisB = analysisFor(learnerB, "사용자 B", 2); 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: 0, ended_sessions: 9, learner_growth: [ { ...analysisA.summary, points: analysisA.points.slice(-6) }, { ...analysisB.summary, points: analysisB.points }, ], safety_alerts: [], pending_reviews: [], recent_sessions: analysisA.sessions.slice(-2), message: "사용자별 분석 fixture.", }), }), ); await page.route("**/api/teacher/learners/*/analysis", (route) => { const path = new URL(route.request().url()).pathname; const body = path.includes(learnerB) ? analysisB : analysisA; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body), }); }); 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.getByRole("link", { name: "학생 분석" })).toBeVisible(); await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0); await page.getByRole("link", { name: "학생 분석" }).click(); await expect(page).toHaveURL(/\/teach\/analysis$/); const overviewTable = page.locator('[data-learner-overview-table="true"]'); await expect(overviewTable).toBeVisible(); await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(2); await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0); await page.getByLabel("학습자 검색").fill("사용자 B"); const learnerBRow = page .locator('[data-learner-overview-row="true"]') .filter({ hasText: "사용자 B" }); await expect(learnerBRow).toHaveCount(1); await expect(learnerBRow.getByText(learnerB)).toBeVisible(); await expect(learnerBRow.getByRole("button", { name: "사용자 B", exact: true })).toBeVisible(); await learnerBRow.getByRole("button", { name: "사용자 B 요약 펼치기" }).click(); await expect(page.locator('[data-learner-expanded-row="true"]')).toBeVisible(); await expect(page.locator('[data-learner-expanded-row="true"]').getByText("회기별 추이")).toBeVisible(); await learnerBRow.getByRole("button", { name: "사용자 B 상세 보기" }).click(); await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerB}$`)); const analysisPanel = page.locator('[data-learner-analysis-panel="true"]'); await expect(analysisPanel).toBeVisible(); await expect(analysisPanel.getByRole("tab", { name: /^페르소나별 회기/ })).toHaveAttribute( "aria-selected", "true", ); await expect(analysisPanel.locator('[data-learner-persona-group="true"]')).toHaveCount(2); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0); const personaGroup = analysisPanel .locator('[data-learner-persona-group="true"]') .filter({ hasText: "민재" }); await personaGroup.getByRole("button", { name: /P2 민재 회기 펼치기/ }).click(); await expect(analysisPanel.locator('[data-learner-persona-sessions="true"]')).toBeVisible(); await expect( analysisPanel.locator('[data-learner-persona-sessions="true"]').getByText(`${learnerB}-session-2`), ).toBeVisible(); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(1); await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click(); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2); await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible(); await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0); await expect(page.getByText("전체 회기 2건")).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-overview-row="true"]')).toHaveCount(1); 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("surfaces failed AI session evaluation in the teacher pending queue", async ({ page, }) => { const sessionId = "teacher-failed-ai-evaluation-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-000000000116", learner_label: "하린", persona_code: "P6", persona_name: "하린", session_no: 6, status: "ended", stage: "정리", turn_count: 18, learner_turn_count: 9, client_turn_count: 9, started_at: "2026-06-30T05:00:00Z", ended_at: "2026-06-30T06:42:33Z", review_status: "pending", review_note: null, reviewed_at: null, evaluation_status: "error", review_ready: false, supervisor_state: "평가 실패", evaluation_error: "session evaluation timeout after 45s", }, ], recent_sessions: [], message: "교수자 검토 대기 회기가 있습니다.", }), }), ); await page.route("**/api/personas/review", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "[]", }), ); await page.route("**/api/auth/me", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ 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: "", }), }), ); await page.goto("/teach"); const row = page.getByRole("button", { name: /하린 P6 회기 상세 검토/ }); await expect(row).toBeVisible(); await expect(row).toContainText("평가 실패"); await expect(row).toContainText("검토 대기"); }); 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); }); });