import { expect, test, type Page, type Response } from "@playwright/test"; import { FILLED_REVIEW_SESSION_ID, filledReviewResponse, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner, } 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 routeSupervisorCapableLearner(page: Page) { await page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "00000000-0000-0000-0000-000000000303", email: "yunchan@twentyoz.kr", role: "learner", display_name: "Yun Chan", admin_access: true, super_admin: true, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "Yun Chan", self_introduction: "", avatar_url: "", }), }); }); } function isReviewResponse(sessionId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith(`/sessions/${sessionId}/review`) ); }; } async function createEndedSession(page: Page) { const persona = await fetchAvailablePersona(page, 1); 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; } async function expectReviewRegionsReachable(page: Page) { await expect(page.locator(".sr-overview")).toBeVisible(); await expect(page.locator(".sr-feedback")).toBeVisible(); await expect(page.locator(".sr-card--chart")).toBeVisible(); await expect(page.locator(".sr-card--flow")).toBeVisible(); await expect(page.locator(".sr-card--rubric")).toBeVisible(); await expect(page.locator(".sr-card--transcript")).toBeVisible(); await expect(page.locator(".sr-card--prepost")).toBeVisible(); const grid = await page.locator(".sr-cols").evaluate((el) => { const style = window.getComputedStyle(el); return { display: style.display, areas: style.gridTemplateAreas, }; }); expect(grid.display).toBe("grid"); expect(grid.areas).toContain("overview"); expect(grid.areas).toContain("feedback"); expect(grid.areas).toContain("transcript"); expect(grid.areas).not.toContain("teacher"); await expect(page.locator(".sr-overview")).toBeInViewport(); await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded(); await expect(page.locator(".sr-card--transcript")).toBeInViewport(); const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => { const root = gridEl.closest(".sr-root"); const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect(); const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect(); const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect(); const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect(); const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect(); const columns = window .getComputedStyle(gridEl) .gridTemplateColumns.split(" ") .filter(Boolean).length; return { isEmpty: root?.classList.contains("sr-root--empty") ?? false, columns, overviewLeft: overview ? Math.floor(overview.left) : 0, overviewRight: overview ? Math.ceil(overview.right) : 0, transcriptLeft: transcript ? Math.floor(transcript.left) : 0, transcriptRight: transcript ? Math.ceil(transcript.right) : 0, rubricLeft: rubric ? Math.floor(rubric.left) : 0, prepostLeft: prepost ? Math.floor(prepost.left) : 0, worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0, }; }); if (!railGeometry.isEmpty && railGeometry.columns === 2) { expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft); expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); } else if (railGeometry.columns >= 3) { expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft); } } test.describe("session review", () => { test("redirects supervisor-capable users from learner 403 into the teacher review route", async ({ page, }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "supervisor-redirect-review"; let reviewAttempts = 0; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewAttempts += 1; const referer = route.request().headers().referer ?? page.url(); if (!referer.includes(`/teach/session/${sessionId}/review`)) { await route.fulfill({ status: 403, contentType: "application/json", body: JSON.stringify({ detail: "session does not belong to user" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(filledReviewResponse(sessionId)), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`)); await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible(); expect(reviewAttempts).toBeGreaterThanOrEqual(2); }); test("normalizes a successful supervisor review response away from the learner route", async ({ page, }) => { await routeSupervisorCapableLearner(page); const sessionId = "supervisor-success-review"; let reviewAttempts = 0; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewAttempts += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...filledReviewResponse(sessionId), teacherReview: { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }, }), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`)); await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible(); await expect(page.locator(".sr-card--prepost")).toHaveCount(0); expect(reviewAttempts).toBeGreaterThanOrEqual(2); }); test("refreshes an ended review from evaluation pending to ready", async ({ page }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-pending-then-ready"; let reviewAttempts = 0; let firstResponseWasPending = false; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewAttempts += 1; const ready = { ...filledReviewResponse(sessionId), summary: "평가 AI가 저장된 축어록을 분석했습니다.", }; const pending = { ...ready, supervisorState: "평가 대기", summary: "평가 AI가 저장된 축어록을 분석 중입니다.", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, }; if (reviewAttempts === 1) firstResponseWasPending = true; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(reviewAttempts === 1 ? pending : ready), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible({ timeout: 5000, }); await expect(page.getByText("평가 완료")).toBeVisible(); expect(firstResponseWasPending).toBe(true); expect(reviewAttempts).toBeGreaterThanOrEqual(2); }); test("lets supervisor retry a failed AI session evaluation", async ({ page }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-failed-retry"; let reevaluateRequests = 0; let retrySucceeded = false; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { const ready = filledReviewResponse(sessionId); const failed = { ...ready, supervisorState: "평가 실패", summary: "저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: engine_error: evaluator timeout", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, teacherReview: { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }, }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(retrySucceeded ? ready : failed), }); }); await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => { reevaluateRequests += 1; retrySucceeded = true; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ loop: "deep", session_id: sessionId, stage: "정리", scope: "session_end", turns_evaluated: 2, distribution: {}, theory_mode: "humanistic", strengths: [], improvements: [], intent_deviations: [], error: null, }), }); }); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.getByText("평가 실패")).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible(); await page.getByRole("button", { name: "AI 평가 재시도" }).click(); await expect(page.getByText("평가 완료")).toBeVisible(); await expect(page.getByText("평가 실패")).toHaveCount(0); expect(reevaluateRequests).toBe(1); }); test("keeps the failed review visible when AI evaluation retry fails", async ({ page }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-failed-retry-error"; let reviewRequests = 0; let reevaluateRequests = 0; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewRequests += 1; const ready = filledReviewResponse(sessionId); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...ready, supervisorState: "평가 실패", summary: "저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: session evaluation timeout after 45s", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, teacherReview: { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }, }), }); }); await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => { reevaluateRequests += 1; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "engine_error: retry timeout" }), }); }); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.getByText("평가 실패")).toBeVisible(); const reviewRequestsBeforeRetry = reviewRequests; await page.getByRole("button", { name: "AI 평가 재시도" }).click(); await expect(page.getByText("engine_error: retry timeout")).toBeVisible(); await expect(page.getByText("평가 실패")).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled(); expect(reevaluateRequests).toBe(1); expect(reviewRequests).toBe(reviewRequestsBeforeRetry); }); test("renders server review data without legacy transcript fixtures", async ({ page }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = await createEndedSession(page); const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId)); await page.goto(`/learn/session/${sessionId}/review`); const reviewResponse = await reviewResponsePromise; await expectResponseOk(reviewResponse); const review = await reviewResponse.json(); expect(review.session_id).toBe(sessionId); expect(review.reviewReady).toBe(false); expect(review.turns).toHaveLength(0); await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`)); await expect(page.getByText("축어록 없음")).toBeVisible(); await expect(page.getByText("감정 타임라인 대기")).toBeVisible(); await expect(page.getByText("개선점 대기")).toBeVisible(); await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled(); await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled(); await expect(page.getByText("32분 14초")).toHaveCount(0); await expect(page.getByText("시연")).toHaveCount(0); await expectReviewRegionsReachable(page); const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) => buttons.map((button) => { const rect = button.getBoundingClientRect(); const style = window.getComputedStyle(button); return { height: Math.ceil(rect.height), whiteSpace: style.whiteSpace, }; }), ); expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe( true, ); await expectNoHorizontalOverflow(page); }); test("renders filled review evidence state without clipping", async ({ page }) => { await signInAsLearner(page); await routeFilledSessionReview(page); await routePrepostMeasures(page); const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID)); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); const reviewResponse = await reviewResponsePromise; await expectResponseOk(reviewResponse); const review = await reviewResponse.json(); expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID); expect(review.reviewReady).toBe(true); expect(review.turns).toHaveLength(6); await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`)); await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible(); await expect(page.getByText("감정 밸런스 타임라인")).toBeVisible(); await expect(page.getByText("회기 흐름")).toBeVisible(); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await expect(page.getByText("기법 사용 분포")).toBeVisible(); await expect(page.getByText("파일럿 증거 원장")).toBeVisible(); await expect(page.getByText("1/3 쌍")).toBeVisible(); const prepostInputs = page.locator(".sr-card--prepost input"); await expect(prepostInputs).toHaveCount(6); await expect(prepostInputs.nth(0)).toHaveValue("3"); await expect(prepostInputs.nth(1)).toHaveValue("4"); const prepostLabels = await prepostInputs.evaluateAll((inputs) => inputs.map((input) => input.getAttribute("aria-label")), ); expect(prepostLabels).toEqual([ "자기효능감 사전 점수", "자기효능감 사후 점수", "기술숙련도 사전 점수", "기술숙련도 사후 점수", "수련만족도 사전 점수", "수련만족도 사후 점수", ]); await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible(); await expect(page.getByText("사례개념화 워크시트")).toBeVisible(); await expect(page.getByText("감정 반영").first()).toBeVisible(); await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible(); await expect(page.getByText("다음에 시도할 문장")).toBeVisible(); await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible(); await expect(page.getByText("개입 전 준비도 확인")).toBeVisible(); await expect(page.locator(".sr-chart__svg")).toBeVisible(); await expect(page.locator(".sr-ws-input").first()).toBeVisible(); await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeEnabled(); await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeEnabled(); await expectReviewRegionsReachable(page); await expectNoHorizontalOverflow(page); }); test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => { await signInAsLearner(page); await routeFilledSessionReview(page); await routePrepostMeasures(page); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 }); const prepostInputs = page.locator(".sr-card--prepost input"); await expect(prepostInputs).toHaveCount(6); await expect(page.getByText("1/3 쌍")).toBeVisible(); await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible(); await prepostInputs.nth(2).fill("2.5"); await prepostInputs.nth(3).fill("3.5"); await expect(prepostInputs.nth(2)).toHaveValue("2.5"); await expect(prepostInputs.nth(3)).toHaveValue("3.5"); await page.getByRole("button", { name: "점수 저장" }).click(); await expect(page.getByText("2/3 쌍")).toBeVisible(); await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준"); }); });