/** * 전수 순회(2026-07-27) — 회기 리뷰(session-review) 영역의 "신규 spec 필요" 항목 검증. * * docs/ops/e2e-full-sweep-2026-07-27.md §4에서 기존 spec이 덮지 않던 기능을 * route fixture(리뷰 응답·공유·교수자 검수 PUT) + 실 dev-login 조합으로 고정해 검증한다. * 실제 AI 엔진 턴 생성은 하지 않는다. * * 대상 checklist id: * - session-review-guard-require-auth / session-review-guard-pending-onboarding * - session-review-error-state * - session-review-btn-audio / session-review-btn-pdf * - session-review-btn-share / session-review-share-note * - session-review-transcript-filter / session-review-turn-jump * - session-review-phase-flow / session-review-rubric-display * - session-review-good-growth-points / session-review-worksheet-evidence-jump * - session-review-teacher-worksheet-note / session-review-teacher-worksheet-reject */ import { expect, test, type Page } from "@playwright/test"; import { filledReviewResponse, routeEmptySessionReview, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; import { signInAsLearner, signInAsTeacher } from "./support"; /** 리뷰 상단 가로 탭 전환(기존 session-review.spec.ts 헬퍼와 동일한 계약). */ async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") { const tabs = page.locator(".sr-tabs"); await tabs.waitFor({ state: "attached", timeout: 10_000 }); await tabs.locator("button", { hasText: label }).click(); } function routeAuthMe( page: Page, overrides: { role?: "learner" | "teacher"; account_status?: string; onboarding_completed_at?: number | null; } = {}, ) { return page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "00000000-0000-0000-0000-000000000101", email: "learner@hs.ac.kr", role: overrides.role ?? "learner", display_name: "E2E Learner", admin_access: false, super_admin: false, account_status: overrides.account_status ?? "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: overrides.onboarding_completed_at === undefined ? 1782820001 : overrides.onboarding_completed_at, nickname: "E2E Learner", self_introduction: "", avatar_url: "", }), }); }); } test.describe("full-sweep session review", () => { // checklist: session-review-guard-require-auth test("redirects unauthenticated and role-mismatched users away from review routes", async ({ page, }) => { // 미인증 → /login (state.from 회수용 리다이렉트). await page.goto("/learn/session/guard-check/review"); await expect(page).toHaveURL(/\/login$/); // learner가 교수자 리뷰 경로에 오면 자기 역할 홈(/learn)으로 replace. await signInAsLearner(page); await page.goto("/teach/session/guard-check/review"); await expect(page).toHaveURL(/\/learn$/); // teacher가 학습자 리뷰 경로에 오면 자기 역할 홈(/teach)으로 replace. await signInAsTeacher(page); await page.goto("/learn/session/guard-check/review"); await expect(page).toHaveURL(/\/teach$/); }); // checklist: session-review-guard-pending-onboarding test("recovers pending and onboarding-incomplete users from review routes", async ({ page, }) => { await signInAsLearner(page); // 미승인 계정은 어느 리뷰 경로에서든 /pending으로 회수된다. await routeAuthMe(page, { account_status: "pending" }); await page.goto("/learn/session/guard-check/review"); await expect(page).toHaveURL(/\/pending$/); // 온보딩 미완료(승인됨)는 /onboarding으로 회수된다. await page.unroute("**/api/auth/me"); await routeAuthMe(page, { onboarding_completed_at: null }); await page.goto("/learn/session/guard-check/review"); await expect(page).toHaveURL(/\/onboarding$/); }); // checklist: session-review-error-state test("shows the error card with the failure message when the review load fails", async ({ page, }) => { await signInAsLearner(page); const sessionId = "review-load-error"; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "review backend down" }), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page.getByText("리뷰를 표시할 수 없습니다")).toBeVisible(); await expect(page.getByText("API 500: review backend down")).toBeVisible(); }); // checklist: session-review-btn-audio, session-review-btn-pdf test("opens audio and pdf export urls in a new tab only when provided", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-audio-pdf"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); const audioButton = page.getByRole("button", { name: "오디오 다시 듣기" }); const pdfButton = page.getByRole("button", { name: "PDF 내보내기" }); await expect(audioButton).toBeEnabled(); await expect(pdfButton).toBeEnabled(); const [audioPopup] = await Promise.all([ page.context().waitForEvent("page"), audioButton.click(), ]); await expect .poll(() => audioPopup.url()) .toContain("/mock/reviews/filled-review-visual.mp3"); await audioPopup.close(); const [pdfPopup] = await Promise.all([ page.context().waitForEvent("page"), pdfButton.click(), ]); await expect .poll(() => pdfPopup.url()) .toContain("/mock/reviews/filled-review-visual.pdf"); await pdfPopup.close(); // URL이 없는 회기에서는 두 버튼 모두 비활성화된다. const emptyId = "full-sweep-audio-pdf-empty"; await routeEmptySessionReview(page, emptyId); await page.goto(`/learn/session/${emptyId}/review`); await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled(); await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled(); }); // checklist: session-review-btn-share, session-review-share-note test("creates a share url, flips the button label, and shows the url note", async ({ page, }) => { await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-share-success"; await routeFilledSessionReview(page, sessionId); const shareUrl = "https://vignette.example.test/share/full-sweep-share-success"; let shareRequests = 0; await page.route(`**/api/sessions/${sessionId}/share`, async (route) => { shareRequests += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ shareUrl, title: "회기 리뷰 공유", description: "테스트 공유 링크", imageUrl: "https://vignette.example.test/share/full-sweep.png", createdAt: "2026-07-27T00:00:00Z", }), }); }); await page.goto(`/learn/session/${sessionId}/review`); const shareButton = page.getByRole("button", { name: "공유 URL 복사" }); await expect(shareButton).toBeEnabled(); await shareButton.click(); await expect(page.getByRole("button", { name: "공유 URL 복사됨" })).toBeVisible(); const note = page.locator(".sr-share-note"); await expect(note).toBeVisible(); await expect(note).toContainText(shareUrl); await expect(note).not.toHaveClass(/sr-share-note--error/); expect(shareRequests).toBe(1); }); // checklist: session-review-btn-share, session-review-share-note test("keeps the share button usable and shows the error note when share creation fails", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-share-error"; await routeFilledSessionReview(page, sessionId); await page.route(`**/api/sessions/${sessionId}/share`, async (route) => { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "share backend unavailable" }), }); }); await page.goto(`/learn/session/${sessionId}/review`); await page.getByRole("button", { name: "공유 URL 복사" }).click(); const note = page.locator(".sr-share-note"); await expect(note).toBeVisible(); await expect(note).toHaveClass(/sr-share-note--error/); await expect(note).toContainText("API 503: share backend unavailable"); // 실패 후 라벨은 '복사됨'으로 넘어가지 않고 재시도 가능해야 한다. await expect(page.getByRole("button", { name: "공유 URL 복사" })).toBeEnabled(); }); // checklist: session-review-transcript-filter test("toggles the transcript between all turns and learner-only turns", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-transcript-filter"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); const filters = page.locator(".sr-tx__filters"); const allChip = filters.getByRole("button", { name: "전체" }); const mineChip = filters.getByRole("button", { name: "내 발화만" }); // 동맹 펄스 카드(AlliancePulseCard)의 근거 장면 선택기가 같은 발화를 인용하므로 // 발화 본문 확인은 축어록 탭패널 안으로 한정한다(strict mode 중복 방지). const transcript = page.locator("#sr-panel-transcript"); await expect(allChip).toHaveAttribute("aria-pressed", "true"); await expect(mineChip).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".sr-turn")).toHaveCount(6); await expect( transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }), ).toBeVisible(); await mineChip.click(); await expect(mineChip).toHaveAttribute("aria-pressed", "true"); await expect(allChip).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".sr-turn")).toHaveCount(3); await expect( transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }), ).toBeHidden(); await expect( transcript.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }), ).toBeVisible(); // 축어록이 없으면 두 필터 칩 모두 비활성화된다. const emptyId = "full-sweep-transcript-filter-empty"; await routeEmptySessionReview(page, emptyId); await page.goto(`/learn/session/${emptyId}/review`); await expect(page.locator(".sr-tx__filters").getByRole("button", { name: "전체" })).toBeDisabled(); await expect( page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }), ).toBeDisabled(); }); // checklist: session-review-turn-jump test("jumps to the referenced turn and auto-clears the learner-only filter", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-turn-jump"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }).click(); await expect(page.locator(".sr-turn")).toHaveCount(3); // 개선점 '비교 장면 좁히기'는 내담자 발화 t3(12:41)로 점프한다. await openReviewTab(page, "피드백"); await page.getByRole("button", { name: "12:41 발화로 이동" }).click(); await openReviewTab(page, "축어록"); // 내담자 발화 점프이므로 '내 발화만' 필터가 자동 해제되어야 한다. await expect( page.locator(".sr-tx__filters").getByRole("button", { name: "전체" }), ).toHaveAttribute("aria-pressed", "true"); const activeTurn = page.locator(".sr-turn--active"); await expect(activeTurn).toHaveCount(1); await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서"); }); // checklist: session-review-phase-flow test("renders the phase flow bar with proportional tooltips and its empty state", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-phase-flow"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const phases = page.locator(".sr-phasebar__track .sr-phase"); await expect(phases).toHaveCount(4); await expect(phases.nth(0)).toContainText("라포"); await expect(phases.nth(0)).toHaveAttribute("title", "라포 · 22%"); await expect(phases.nth(1)).toHaveAttribute("title", "탐색 · 34%"); await expect(phases.nth(2)).toHaveAttribute("title", "개입 · 26%"); await expect(phases.nth(3)).toHaveAttribute("title", "정리 · 18%"); const axis = page.locator(".sr-phasebar__axis"); await expect(axis).toContainText("0:00"); await expect(axis).toContainText("32:14"); // 단계 데이터가 없으면 빈 상태 문구를 보여준다. const emptyId = "full-sweep-phase-flow-empty"; await routeEmptySessionReview(page, emptyId); await page.goto(`/learn/session/${emptyId}/review`); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--flow")).toContainText("흐름 데이터 없음"); }); // 결정 D7: 감정 밸런스 타임라인 — fixture 포인트로 차트 path 렌더 확인 test("renders the valence timeline chart paths from fixture points", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-valence-chart"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const chartCard = page.locator(".sr-card--chart"); await expect(chartCard.locator(".sr-chart")).toBeVisible(); // 실선(내담자)·점선(상담자 기준선) path 둘 다 다점 곡선(d="M… C…")으로 그려진다. const paths = chartCard.locator(".sr-chart__svg path"); await expect(paths).toHaveCount(2); const dValues = await paths.evaluateAll((els) => els.map((el) => el.getAttribute("d") ?? ""), ); for (const d of dValues) { expect(d).toMatch(/^M/); expect(d).toContain("C"); } // 시간축 라벨은 fixture valenceAxis 5개를 그대로 사용한다. await expect(chartCard.locator(".sr-chart__xaxis span")).toHaveCount(5); // 포인트가 없으면 차트 대신 빈 상태 카드가 노출된다. const emptyId = "full-sweep-valence-chart-empty"; await routeEmptySessionReview(page, emptyId); await page.goto(`/learn/session/${emptyId}/review`); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--chart")).toContainText("감정 타임라인 대기"); await expect(page.locator(".sr-card--chart .sr-chart")).toHaveCount(0); }); // checklist: session-review-rubric-display test("renders rubric frequency bars with quality badges and the pending state", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-rubric"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const rubricCard = page.locator(".sr-card--rubric"); await expect(rubricCard.locator(".sr-rubric__row")).toHaveCount(4); const reflectRow = rubricCard.locator(".sr-rubric__row", { hasText: "감정 반영" }); await expect(reflectRow.locator(".sr-rubric__qual")).toHaveText(/적절/); await expect(reflectRow).toContainText("정서 확인 · 6회"); const paceRow = rubricCard.locator(".sr-rubric__row", { hasText: "개입 속도" }); await expect(paceRow.locator(".sr-rubric__qual")).toHaveText(/살펴보기/); const reflectBar = page.getByRole("progressbar", { name: "감정 반영 사용 빈도" }); await expect(reflectBar).toBeVisible(); await expect(reflectBar).toHaveAttribute("aria-valuenow", "82"); // 평가가 없으면 대기 상태를 보여준다. const emptyId = "full-sweep-rubric-empty"; await routeEmptySessionReview(page, emptyId); await page.goto(`/learn/session/${emptyId}/review`); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--rubric")).toContainText("평가 대기"); await expect( page.locator(".sr-card--rubric").locator(".sr-rubric__row"), ).toHaveCount(0); }); // checklist: session-review-good-growth-points test("lists good moments and growth points with jump anchors capped at three", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-good-growth"; const response = filledReviewResponse(sessionId); response.growthPoints = [ ...response.growthPoints, { title: "세 번째 개선점", body: "표시 한도 검증용 항목 3.", jumpTo: null }, { title: "네 번째 개선점", body: "최대 3개 초과분은 표시되지 않아야 한다.", jumpTo: null }, ]; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(response), }); }); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const goodCard = page.locator(".sr-card--good"); await expect(goodCard).toContainText("방어를 낮춘 감정 반영"); await expect(goodCard).toContainText("정서 정상화"); await expect(goodCard.getByRole("button", { name: "05:04 발화로 이동" })).toBeVisible(); await expect(goodCard.getByRole("button", { name: "16:05 발화로 이동" })).toBeVisible(); const growthCard = page.locator(".sr-card--growth"); await expect(growthCard).toContainText("개입 전 준비도 확인"); await expect(growthCard).toContainText("비교 장면 좁히기"); await expect(growthCard).toContainText("세 번째 개선점"); await expect(growthCard.locator(".sr-point")).toHaveCount(3); await expect(growthCard.getByText("네 번째 개선점")).toHaveCount(0); await expect(growthCard.getByRole("button", { name: "24:18 발화로 이동" })).toBeVisible(); }); // checklist: session-review-worksheet-evidence-jump test("jumps from a worksheet evidence quote to the transcript turn", async ({ page, }) => { await signInAsLearner(page); await routePrepostMeasures(page); const sessionId = "full-sweep-ws-evidence"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); const evidenceButton = page .getByRole("button", { name: "내담자 근거 발화로 이동" }) .first(); await expect(evidenceButton).toContainText("다른 친구들이랑 비교할 때마다"); await evidenceButton.click(); await openReviewTab(page, "축어록"); const activeTurn = page.locator(".sr-turn--active"); await expect(activeTurn).toHaveCount(1); await expect(activeTurn).toContainText("계속 제가 이상한 사람 같았어요."); }); // checklist: session-review-teacher-worksheet-note, session-review-teacher-worksheet-reject test("saves the teacher worksheet rejection together with the review note", async ({ page, }) => { await signInAsTeacher(page); const sessionId = "full-sweep-ws-reject"; const response = filledReviewResponse(sessionId); if (!response.caseWorksheet) throw new Error("fixture must include a worksheet"); response.caseWorksheet.status = "saved_by_learner"; response.teacherReview = { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(response), }); }); const worksheetNote = "핵심 정서 근거를 보강해 주세요."; let releaseSave: (() => void) | null = null; const saveGate = new Promise((resolve) => { releaseSave = resolve; }); let savedBody: Record | null = null; await page.route( `**/api/teacher/sessions/${sessionId}/review-status`, async (route) => { savedBody = route.request().postDataJSON() as Record; await saveGate; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, status: "viewed", note: String(savedBody.note ?? ""), reviewer_id: "00000000-0000-0000-0000-000000000202", reviewed_at: null, updated_at: "2026-07-27 10:00", worksheet_status: "rejected", worksheet_note: String(savedBody.worksheet_note ?? ""), worksheet_reviewed_at: "2026-07-27 10:00", }), }); }, ); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const worksheetPanel = page.locator(".sr-teacher-review__worksheet"); await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText( "검수 대기", ); await page .getByPlaceholder("수정요청이나 반려 사유를 간단히 남깁니다.") .fill(worksheetNote); const approveButton = worksheetPanel.getByRole("button", { name: "승인" }); const changesButton = worksheetPanel.getByRole("button", { name: "수정요청" }); const rejectButton = worksheetPanel.getByRole("button", { name: "반려" }); await expect(approveButton).toBeEnabled(); await expect(rejectButton).toBeEnabled(); await rejectButton.click(); // 저장 중에는 세 결정 버튼이 모두 비활성화된다(응답은 saveGate로 붙잡아 둔 상태). await expect(worksheetPanel.getByRole("button", { name: "저장 중" })).toBeVisible(); await expect(approveButton).toBeDisabled(); await expect(changesButton).toBeDisabled(); releaseSave?.(); await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText( "반려", ); await expect(worksheetPanel).toContainText("워크시트 검수 시각 2026-07-27 10:00"); expect(savedBody).not.toBeNull(); expect(savedBody!["worksheet_status"]).toBe("rejected"); expect(savedBody!["worksheet_note"]).toBe(worksheetNote); expect(savedBody!["status"]).toBe("viewed"); }); });