/** * 유스케이스 스펙 — 회기 리뷰 핵심 여정 (uc-session-review-core) * * 목적: 학습자·교수자가 회기 리뷰 화면에서 실제로 수행하는 핵심 행동 * (축어록 턴 평가 확인, 근거 패널 열기/닫기, 근거 인용·앵커 이동, * 워크시트 작성·저장, 치료 동맹 자기평가 잠금, 평가 재시도)을 검증한다. * * 근거: apps/web/src/pages/SessionReview.tsx, session-review/model.ts, * session-review/AlliancePulseCard.tsx, e2e/session-review-fixture.ts. * 모든 API는 route fixture로 목킹하며 실제 AI 엔진 턴 생성은 없다. * * 중복 회피: session-review.spec.ts(폴링·리다이렉트·플레이스홀더 마스킹·prepost 저장), * full-sweep-session-review.spec.ts(가드·공유·필터·client 턴 점프·루브릭·교수자 검수)와 * 겹치지 않는 각도만 다룬다. */ import { expect, test, type Page } from "@playwright/test"; import { filledReviewResponse, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } /** 이 스펙이 목킹하지 않은 API는 전부 404 — 실 API 없이 결정론적으로 돈다. */ async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } async function routeAuthedLearner(page: Page) { await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "00000000-0000-0000-0000-000000000101", email: "learner@hs.ac.kr", role: "learner", display_name: "학습자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "학습자", self_introduction: "", avatar_url: "", }), ), ); } async function routeAuthedTeacher(page: Page) { await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "00000000-0000-0000-0000-000000000202", email: "teacher@hs.ac.kr", role: "teacher", display_name: "교수자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "교수자", self_introduction: "", avatar_url: "", }), ), ); } /** 리뷰는 전 폭에서 상단 가로 탭으로 영역을 전환한다(SessionReview.tsx REVIEW_TABS). */ async function openReviewTab( page: Page, label: "축어록" | "피드백" | "워크시트", ) { const tabs = page.locator(".sr-tabs"); await tabs.waitFor({ state: "attached", timeout: 10_000 }); await tabs.getByRole("tab", { name: label }).click(); } /** 공개(revealed)된 치료 동맹 펄스 — 관점 비교와 근거 패널이 렌더되는 상태. */ function revealedPulse() { return { pulse_id: "pulse-ready-1", checkpoint: "post", status: "ready", learner_locked_at: "2026-06-27T10:00:00+00:00", revealed_at: "2026-06-27T10:05:00+00:00", error_code: null, self_scores: { goal: 0.75, task: 0.5, bond: 0.75 }, measurements: [ { measurement_id: "m-goal-client", dimension: "goal", perspective: "client_agent_report", source_kind: "client_agent", value: 0.5, confidence: 0.8, status: "ready", error_code: null, rationale: "내담자는 이번 회기에서 다루려는 주제를 대체로 이해했다고 보고했습니다.", evidence: [ { turn_id: "t3", seq: 3, speaker: "client", text: "그때는 그냥 화가 난다고 말하면 제가 더 나쁜 사람이 될 것 같아서 아무 말도 못 했어요.", }, ], created_at: "2026-06-27T10:05:00+00:00", }, ], }; } test.describe("uc session review core", () => { // usecase: 학습자가 축어록에서 자기 발화에 붙은 AI 턴 평가 상세와 발화 근거 인용을 확인한다 test("축어록 턴에 붙은 AI 평가 상세와 발화 근거 인용을 확인한다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-turn-note-detail"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); // 기본 탭은 축어록 — t2 학습자 발화의 긍정 평가 노트가 열려 있다. const goodNote = page.locator(".sr-note", { hasText: "좋은 연결" }); await expect(goodNote).toBeVisible(); await expect(goodNote).toContainText( "내담자의 경험을 평가하지 않고 정서와 의미를 함께 되짚었습니다.", ); await expect(goodNote.locator(".sr-note__quote")).toContainText("발화 근거"); await expect(goodNote.locator(".sr-note__quote")).toContainText( "혼자서 버티고 있었다는 느낌", ); // t5 학습자 발화의 주의(warn) 톤 노트도 근거 인용과 함께 표시된다. const warnNote = page.locator(".sr-note", { hasText: "속도 점검" }); await expect(warnNote).toHaveClass(/sr-note--warn/); await expect(warnNote.locator(".sr-note__quote")).toContainText( "연습부터 해볼 수 있을까요", ); }); // usecase: 학습자가 관점 비교에서 AI 측정의 근거 상세 패널을 열었다가 닫는다 test("치료 동맹 관점 비교의 근거 상세 패널을 열고 닫는다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-evidence-panel-toggle"; await routeFilledSessionReview(page, sessionId); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [revealedPulse()] })), ); await page.goto(`/learn/session/${sessionId}/review`); const comparison = page.locator(".ap-comparison"); await expect(comparison).toBeVisible(); const evidencePanel = comparison.locator("details.ap-measurement-evidence"); await expect(evidencePanel).toHaveCount(1); const rationale = evidencePanel.locator("p", { hasText: "다루려는 주제를 대체로 이해했다고", }); await expect(rationale).toBeHidden(); // 열기 — 판단 근거 서술과 근거 발화 버튼이 함께 나타난다. await evidencePanel.locator("summary").click(); await expect(rationale).toBeVisible(); await expect( evidencePanel.getByRole("button", { name: /3번째 발화/ }), ).toBeVisible(); // 닫기 — 상세가 다시 숨는다. await evidencePanel.locator("summary").click(); await expect(rationale).toBeHidden(); }); // usecase: 학습자가 관점 비교의 근거 발화 버튼으로 축어록의 해당 턴으로 이동한다 test("관점 비교의 근거 발화 버튼으로 축어록 턴으로 이동한다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-evidence-jump"; await routeFilledSessionReview(page, sessionId); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [revealedPulse()] })), ); await page.goto(`/learn/session/${sessionId}/review`); const evidencePanel = page.locator("details.ap-measurement-evidence"); await evidencePanel.locator("summary").click(); await evidencePanel.getByRole("button", { name: /3번째 발화/ }).click(); // jumpToTurn은 축어록 탭으로 전환하며 대상 턴을 활성화한다. const activeTurn = page.locator(".sr-turn--active"); await expect(activeTurn).toHaveCount(1); await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서"); }); // usecase: 학습자가 리뷰 화면에서 영문 이론키(raw 토큰) 대신 자연어 라벨만 본다 test("난도·작성자·신뢰도 라벨이 영문 원문 키 없이 자연어로 표시된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-natural-labels"; const response = filledReviewResponse(sessionId); // API가 원문 난도 토큰과 원문 작성자 키를 그대로 보낸 상황을 가정한다. response.client.persona = "P1 · hard · 인본주의"; if (!response.turns[1].note) throw new Error("fixture must include t2 note"); response.turns[1].note = { ...response.turns[1].note, author: "ai" }; await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill(jsonRoute(response)), ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await page.goto(`/learn/session/${sessionId}/review`); // 헤더 페르소나 칩: hard → 고난도 치환(personaMetaParts). const headTags = page.locator(".sr-head__tags"); await expect(headTags.getByText("고난도", { exact: true })).toBeVisible(); await expect(headTags).not.toContainText("hard"); // 턴 노트 작성자 키: "ai" → "AI"(noteAuthorLabel). const goodNote = page.locator(".sr-note", { hasText: "좋은 연결" }); await expect(goodNote.locator(".sr-note__tag")).toHaveText("AI"); // 워크시트 신뢰도 키: medium → "근거 있음"(confidenceLabel). await openReviewTab(page, "워크시트"); const worksheetPanel = page.locator("#sr-panel-worksheet"); await expect(worksheetPanel.locator(".sr-ws-badge").first()).toHaveText( "근거 있음", ); await expect(worksheetPanel).not.toContainText("medium"); }); // usecase: 학습자가 리뷰 세 탭을 순회해도 화면 어디에도 raw UUID가 노출되지 않는다 test("리뷰 세 탭 어디에도 세션 raw UUID가 노출되지 않는다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const uuid = "e2e0a4c1-6c1b-4f0e-9a2e-b6f4f5f6a7b8"; await routeFilledSessionReview(page, uuid); await page.goto(`/learn/session/${uuid}/review`); const root = page.locator(".sr-root"); await expect(page.locator(".sr-card--transcript")).toBeVisible(); await expect(root).not.toContainText(uuid); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--rubric")).toBeVisible(); await expect(root).not.toContainText(uuid); await openReviewTab(page, "워크시트"); await expect(page.locator(".sr-card--worksheet")).toBeVisible(); await expect(root).not.toContainText(uuid); }); // usecase: 학습자가 워크시트 항목을 고쳐 저장하고 제출본 상태를 확인한다 test("워크시트 항목을 수정해 저장하면 학습자 제출본으로 반영된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-worksheet-save-ok"; await routeFilledSessionReview(page, sessionId); const editedValue = "엄마의 비교 발화 직후 자기비난과 분노가 동시에 올라오는 패턴."; let savedBody: Record | null = null; await page.route( `**/api/sessions/${sessionId}/review/worksheet`, async (route) => { savedBody = route.request().postDataJSON() as Record; const sections = savedBody.sections; await route.fulfill( jsonRoute({ status: "saved_by_learner", generatedBy: "rule-based transcript extractor", savedAt: "2026-06-27 10:30", sections, limitations: savedBody.limitations ?? [], }), ); }, ); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); // 초안 상태: 변경 전에는 저장 버튼이 "저장됨"으로 비활성화되어 있다. const worksheetPanel = page.locator("#sr-panel-worksheet"); await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText( "축어록 기반 자동 초안", ); await expect( worksheetPanel.getByRole("button", { name: "저장됨" }), ).toBeDisabled(); await page.getByLabel("촉발 장면 반복 비교").fill(editedValue); const saveButton = worksheetPanel.getByRole("button", { name: "저장" }); await expect(saveButton).toBeEnabled(); await saveButton.click(); await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText( "저장된 학습자 제출본", ); await expect( worksheetPanel.getByRole("button", { name: "저장됨" }), ).toBeDisabled(); await expect(page.getByLabel("촉발 장면 반복 비교")).toHaveValue(editedValue); expect(savedBody).not.toBeNull(); const sentSections = JSON.stringify(savedBody!.sections); expect(sentSections).toContain(editedValue); }); // usecase: 학습자가 워크시트 저장에 실패해도 입력을 잃지 않고 오류를 보고 재시도한다 test("워크시트 저장 실패 시 오류 문구와 함께 입력이 보존된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-worksheet-save-fail"; await routeFilledSessionReview(page, sessionId); await page.route(`**/api/sessions/${sessionId}/review/worksheet`, (route) => route.fulfill(jsonRoute({ detail: "worksheet backend down" }, 500)), ); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); const editedValue = "저장 실패 검증용 수정 내용."; await page.getByLabel("핵심 정서 분노와 수치심").fill(editedValue); const worksheetPanel = page.locator("#sr-panel-worksheet"); await worksheetPanel.getByRole("button", { name: "저장" }).click(); await expect(worksheetPanel.locator(".sr-ws-save-error")).toHaveText( "워크시트를 저장하지 못했습니다.", ); // 초안 상태 유지 + 입력 보존 + 재시도 가능. await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText( "축어록 기반 자동 초안", ); await expect(page.getByLabel("핵심 정서 분노와 수치심")).toHaveValue( editedValue, ); await expect( worksheetPanel.getByRole("button", { name: "저장" }), ).toBeEnabled(); }); // usecase: 학습자가 워크시트 항목을 비웠다가 다시 쓰면 신뢰도 배지가 빈칸→초안으로 바뀐다 test("워크시트 값을 비우면 빈칸, 다시 쓰면 초안 신뢰도 배지로 바뀐다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-worksheet-confidence"; await routeFilledSessionReview(page, sessionId); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); const item = page.locator(".sr-ws-item", { hasText: "반복 비교" }); await expect(item.locator(".sr-ws-badge")).toHaveText("근거 있음"); await page.getByLabel("촉발 장면 반복 비교").fill(""); await expect(item.locator(".sr-ws-badge")).toHaveText("빈칸"); await page.getByLabel("촉발 장면 반복 비교").fill("직접 다시 정리한 촉발 장면."); await expect(item.locator(".sr-ws-badge")).toHaveText("초안"); }); // usecase: 학습자가 회기 종료 후(post) 자기평가 세 축을 잠그고 잠금 표시를 확인한다 test("자기평가 세 축을 잠그면 잠금 상태와 잠긴 점수가 표시된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-alliance-lock"; await routeFilledSessionReview(page, sessionId); let locked = false; let createBody: Record | null = null; await page.route( `**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { createBody = route.request().postDataJSON() as Record; locked = true; await route.fulfill( jsonRoute({ pulse_id: "pulse-locked-1", status: "awaiting_agents" }), ); return; } await route.fulfill( jsonRoute({ items: locked ? [ { pulse_id: "pulse-locked-1", checkpoint: "post", status: "awaiting_agents", learner_locked_at: "2026-06-27T10:10:00+00:00", revealed_at: null, error_code: null, self_scores: { goal: 1, task: 0.75, bond: 0.5 }, measurements: [], }, ] : [], }), ); }, ); await page.goto(`/learn/session/${sessionId}/review`); const apCard = page.locator(".ap-card"); await expect(apCard.locator(".ap-self-form")).toBeVisible(); const submitButton = apCard.getByRole("button", { name: "내 판단 잠그고 관점 비교", }); // 세 축과 근거 발화를 채우기 전에는 잠글 수 없다. await expect(submitButton).toBeDisabled(); // 축별 5점 척도: 목표=5, 과업=4, 유대=3. const axes = apCard.locator(".ap-axis"); await axes.nth(0).locator(".ap-scale__option").nth(4).click(); await axes.nth(1).locator(".ap-scale__option").nth(3).click(); await axes.nth(2).locator(".ap-scale__option").nth(2).click(); // 근거 장면 선택 패널을 열어 첫 발화를 근거로 고른다. const picker = apCard.locator("details.ap-evidence-picker"); await picker.locator("summary").click(); await picker.locator(".ap-evidence-picker__list label").first().click(); await expect(picker.locator("summary")).toContainText("1개 선택"); await expect(submitButton).toBeEnabled(); await submitButton.click(); // 잠금 표시: 상태 배지 + 잠긴 점수(수정 불가 안내). await expect(apCard.getByText("내 판단이 잠겼습니다")).toBeVisible(); await expect(apCard.getByText("관점 분석 중")).toBeVisible(); const lockedScores = apCard.locator(".ap-locked-scores"); await expect(lockedScores).toContainText("100점"); await expect(lockedScores).toContainText("75점"); await expect(lockedScores).toContainText("50점"); expect(createBody).not.toBeNull(); expect(createBody!.checkpoint).toBe("post"); expect(Array.isArray(createBody!.evidence_turn_ids)).toBe(true); expect((createBody!.evidence_turn_ids as string[]).length).toBe(1); }); // usecase: 학습자가 이미 잠긴 자기평가가 있는 상태에서 다시 제출하면 저장된 잠금 기록이 표시된다 test("이미 잠긴 자기평가(409)는 저장된 잠금 기록으로 되돌아간다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-alliance-lock-conflict"; await routeFilledSessionReview(page, sessionId); let conflicted = false; await page.route( `**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { conflicted = true; await route.fulfill( jsonRoute({ detail: "alliance pulse already locked" }, 409), ); return; } await route.fulfill( jsonRoute({ items: conflicted ? [ { pulse_id: "pulse-existing-1", checkpoint: "post", status: "awaiting_agents", learner_locked_at: "2026-06-27T09:00:00+00:00", revealed_at: null, error_code: null, // 서버에 저장돼 있던 기존 점수(로컬 입력과 다른 값). self_scores: { goal: 0.25, task: 0.25, bond: 0.25 }, measurements: [], }, ] : [], }), ); }, ); await page.goto(`/learn/session/${sessionId}/review`); const apCard = page.locator(".ap-card"); const axes = apCard.locator(".ap-axis"); await axes.nth(0).locator(".ap-scale__option").nth(4).click(); await axes.nth(1).locator(".ap-scale__option").nth(4).click(); await axes.nth(2).locator(".ap-scale__option").nth(4).click(); const picker = apCard.locator("details.ap-evidence-picker"); await picker.locator("summary").click(); await picker.locator(".ap-evidence-picker__list label").first().click(); await apCard .getByRole("button", { name: "내 판단 잠그고 관점 비교" }) .click(); // 로컬 입력(100점)이 아니라 서버에 잠긴 기존 기록(25점)이 표시되어야 한다. await expect(apCard.getByText("내 판단이 잠겼습니다")).toBeVisible(); const lockedScores = apCard.locator(".ap-locked-scores"); await expect(lockedScores).toContainText("25점"); await expect(lockedScores).not.toContainText("100점"); }); // usecase: 학습자가 '내 발화만' 필터를 켠 채 좋았던 순간 앵커로 자기 발화에 이동한다 test("좋았던 순간 앵커로 이동해도 학습자 발화면 내 발화만 필터가 유지된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-good-moment-anchor"; 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); // '방어를 낮춘 감정 반영'은 학습자 발화 t2(05:04)로 점프한다. await openReviewTab(page, "피드백"); await page .locator(".sr-card--good") .getByRole("button", { name: "05:04 발화로 이동" }) .click(); // 학습자 발화 점프이므로 필터는 해제되지 않고 유지된다. await expect( page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }), ).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".sr-turn")).toHaveCount(3); const activeTurn = page.locator(".sr-turn--active"); await expect(activeTurn).toHaveCount(1); await expect(activeTurn).toContainText("스스로를 의심하게 됐던 걸까요?"); }); // usecase: 학습자가 성장 포인트를 읽을 때 축어록에 없는 턴으로는 이동 버튼이 생기지 않는다 test("성장 포인트의 대상 턴이 축어록에 없으면 이동 버튼을 만들지 않는다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await routePrepostMeasures(page); const sessionId = "uc-growth-missing-anchor"; const response = filledReviewResponse(sessionId); // 첫 번째 성장 포인트의 jumpTo가 축어록에 존재하지 않는 턴을 가리키는 응답. response.growthPoints[0] = { ...response.growthPoints[0], jumpTo: "turn-not-in-transcript", }; await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill(jsonRoute(response)), ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); const growthCard = page.locator(".sr-card--growth"); // 본문은 그대로 보이되(JumpablePoint), 존재하지 않는 턴으로의 앵커 버튼은 없다. const brokenPoint = growthCard.locator(".sr-point", { hasText: "개입 전 준비도 확인", }); await expect(brokenPoint).toBeVisible(); await expect(brokenPoint.locator(".sr-point__at")).toHaveCount(0); // 정상 앵커(t3 → 12:41)는 여전히 제공된다. await expect( growthCard.getByRole("button", { name: "12:41 발화로 이동" }), ).toBeVisible(); }); // usecase: 교수자가 평가 실패 회기에서 AI 평가를 재시도하고 진행 표시와 성공 반영을 본다 test("AI 평가 재시도 중 진행 라벨을 보이고 성공하면 리뷰에 반영된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); const sessionId = "uc-eval-retry-success"; 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(jsonRoute(retrySucceeded ? ready : failed)); }); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); let releaseRetry: (() => void) | null = null; const retryGate = new Promise((resolve) => { releaseRetry = resolve; }); let retryBody: Record | null = null; await page.route( `**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => { retryBody = route.request().postDataJSON() as Record; await retryGate; retrySucceeded = true; await route.fulfill( jsonRoute({ loop: "deep", session_id: sessionId, stage: "정리", scope: "session_end", turns_evaluated: 6, distribution: {}, theory_mode: "humanistic", strengths: [], improvements: [], intent_deviations: [], error: null, }), ); }, ); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.getByText("평가 실패")).toBeVisible(); await page.getByRole("button", { name: "AI 평가 재시도" }).click(); // 재시도 진행 중에는 실행 중 라벨로 바뀌고 눌 수 없다(응답은 gate로 붙잡아 둔 상태). const runningButton = page.getByRole("button", { name: "AI 평가 실행 중" }); await expect(runningButton).toBeVisible(); await expect(runningButton).toBeDisabled(); releaseRetry?.(); // 성공하면 리뷰를 다시 불러와 완료 상태와 실제 평가 내용이 반영된다. await expect(page.getByText("평가 완료")).toBeVisible(); await expect(page.getByText("평가 실패")).toHaveCount(0); await expect( page.getByText("관계 신호가 안정적으로 회복된 회기"), ).toBeVisible(); await expect( page.getByRole("button", { name: "AI 평가 재시도" }), ).toHaveCount(0); expect(retryBody).not.toBeNull(); expect(retryBody!.scope).toBe("session_end"); }); // usecase: 교수자가 재시도했지만 엔진이 오류를 돌려주면 원문 오류 대신 정제된 안내를 본다 test("재시도 응답에 엔진 오류가 담기면 원문 대신 정제된 안내를 보여준다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); const sessionId = "uc-eval-retry-engine-error"; let reviewRequests = 0; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewRequests += 1; await route.fulfill( jsonRoute({ ...filledReviewResponse(sessionId), 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 page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); // HTTP 200이지만 결과 본문에 엔진 오류가 담긴 경우(503 실패 경로는 기존 스펙이 커버). await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, (route) => route.fulfill( jsonRoute({ loop: "deep", session_id: sessionId, stage: "정리", scope: "session_end", turns_evaluated: 0, distribution: {}, theory_mode: "humanistic", strengths: [], improvements: [], intent_deviations: [], error: "engine_error: evaluator crashed", }), ), ); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.getByText("평가 실패")).toBeVisible(); const requestsBeforeRetry = reviewRequests; await page.getByRole("button", { name: "AI 평가 재시도" }).click(); await expect( page.getByText( "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.", ), ).toBeVisible(); await expect(page.getByText("evaluator crashed")).toHaveCount(0); await expect(page.getByText("평가 실패")).toBeVisible(); await expect( page.getByRole("button", { name: "AI 평가 재시도" }), ).toBeEnabled(); // 실패한 재시도는 리뷰를 다시 불러오지 않는다. expect(reviewRequests).toBe(requestsBeforeRetry); }); // usecase: 교수자가 학습자 워크시트 초안을 읽기 전용으로 검토한다 test("교수자 화면에서는 워크시트가 검토 전용 읽기 상태로 표시된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); const sessionId = "uc-worksheet-readonly"; const response = filledReviewResponse(sessionId); response.teacherReview = { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }; await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill(jsonRoute(response)), ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); const worksheetPanel = page.locator("#sr-panel-worksheet"); await expect(worksheetPanel.locator(".sr-ws-readonly")).toHaveText( "검토 전용", ); await expect(worksheetPanel.locator(".sr-ws-state")).toHaveText( "축어록 자동 초안 읽기 전용", ); // 저장 버튼이 없고, 입력창은 읽기 전용이다. await expect( worksheetPanel.getByRole("button", { name: /저장/ }), ).toHaveCount(0); const firstInput = worksheetPanel.locator(".sr-ws-input").first(); await expect(firstInput).toHaveAttribute("readonly", ""); // 읽기 전용이어도 근거 발화 이동은 가능하다. await expect( worksheetPanel.getByRole("button", { name: "내담자 근거 발화로 이동" }).first(), ).toBeVisible(); }); });