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: "", }), }); }); } async function routeLearnerReviewUser(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-000000000101", email: "learner@hs.ac.kr", role: "learner", display_name: "E2E Learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "E2E Learner", 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 openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") { // 탭 바는 리뷰 레이아웃이 로드된 뒤에만 DOM에 존재한다 — attach를 먼저 기다린다. const tabs = page.locator(".sr-tabs"); await tabs.waitFor({ state: "attached", timeout: 10_000 }).catch(() => {}); const tab = tabs.locator("button", { hasText: label }); if (await tab.isVisible().catch(() => false)) { await tab.click(); } } async function expectReviewRegionsReachable(page: Page) { await expect(page.locator(".sr-overview")).toBeVisible(); await expect(page.locator(".sr-tabs")).toBeVisible(); // 피드백 탭: 인사이트 흐름 + 평가 레일이 함께 도달 가능해야 한다. await openReviewTab(page, "피드백"); 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--prepost")).toBeVisible(); const insightsGrid = await page.locator(".sr-cols").evaluate((el) => { const style = window.getComputedStyle(el); return { display: style.display, areas: style.gridTemplateAreas, columns: style.gridTemplateColumns.split(" ").filter(Boolean).length, }; }); expect(insightsGrid.display).toBe("grid"); expect(insightsGrid.areas).toContain("insight"); expect(insightsGrid.areas).toContain("feedback"); expect(insightsGrid.areas).not.toContain("teacher"); // 전 폭 탭 계약: 활성 탭 하나가 전폭 단일 컬럼을 쓴다(인사이트 위 / 평가 아래). expect(insightsGrid.columns).toBe(1); const stackGeometry = await page.locator(".sr-cols").evaluate((el) => { const insight = el.querySelector(".sr-left")?.getBoundingClientRect(); const feedback = el.querySelector(".sr-right")?.getBoundingClientRect(); return { insightBottom: insight ? Math.ceil(insight.bottom) : 0, feedbackTop: feedback ? Math.floor(feedback.top) : 0, }; }); expect(stackGeometry.insightBottom).toBeLessThanOrEqual(stackGeometry.feedbackTop + 1); // 워크시트 탭. await openReviewTab(page, "워크시트"); await expect(page.locator(".sr-card--worksheet")).toBeVisible(); // 축어록 탭(기본)으로 복귀 — 축어록은 전폭 단일 컬럼. await openReviewTab(page, "축어록"); await expect(page.locator(".sr-card--transcript")).toBeVisible(); const transcriptAreas = await page .locator(".sr-cols") .evaluate((el) => window.getComputedStyle(el).gridTemplateAreas); expect(transcriptAreas).toContain("transcript"); await page.locator(".sr-overview").scrollIntoViewIfNeeded(); await expect(page.locator(".sr-overview")).toBeInViewport(); await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded(); await expect(page.locator(".sr-card--transcript")).toBeInViewport(); // 리뷰 요약은 본문 그리드 위의 전폭 밴드다(도킹/스크롤 부유 금지). const band = await page.locator(".sr-root").evaluate((rootEl) => { const overview = rootEl.querySelector(".sr-overview")?.getBoundingClientRect(); const gridRect = rootEl.querySelector(".sr-cols")?.getBoundingClientRect(); return { overviewBottom: overview ? Math.ceil(overview.bottom) : 0, gridTop: gridRect ? Math.floor(gridRect.top) : 0, }; }); expect(band.overviewBottom).toBeLessThanOrEqual(band.gridTop + 1); } 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 openReviewTab(page, "피드백"); 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 openReviewTab(page, "피드백"); await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible(); await expect(page.locator(".sr-card--prepost")).toHaveCount(0); expect(reviewAttempts).toBeGreaterThanOrEqual(2); }); test("shows supervisor evaluation scope boundaries", async ({ page }) => { await routeSupervisorCapableLearner(page); const sessionId = "supervisor-evaluation-scope"; await routeFilledSessionReview(page, sessionId); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "피드백"); await expect(page.getByText("AI 평가 범위", { exact: true })).toBeVisible(); await expect(page.getByText("현재 구현", { exact: true })).toBeVisible(); await expect(page.getByText(/fast-loop.*deep-loop/)).toBeVisible(); await expect(page.getByText("임상팀 확정 필요", { exact: true })).toBeVisible(); }); 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("keeps polling long-running session evaluation until the ready review arrives", async ({ page, }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-delayed-ready"; let reviewAttempts = 0; const readyAtAttempt = 10; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { reviewAttempts += 1; const ready = { ...filledReviewResponse(sessionId), summary: "평가 AI가 늦게 완료된 deep-loop 결과를 반영했습니다.", }; const pending = { ...ready, supervisorState: "평가 대기", summary: "평가 AI가 긴 회기 축어록을 분석 중입니다.", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(reviewAttempts >= readyAtAttempt ? ready : pending), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page.getByText("평가 AI가 늦게 완료된 deep-loop 결과를 반영했습니다.")).toBeVisible({ timeout: 20_000, }); await expect(page.getByText("평가 완료")).toBeVisible(); expect(reviewAttempts).toBeGreaterThanOrEqual(readyAtAttempt); }); test("does not offer manual AI retry while session evaluation is still pending", async ({ page, }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-pending-no-manual-retry"; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { const pending = { ...filledReviewResponse(sessionId), supervisorState: "평가 대기", summary: "평가 AI가 저장된 축어록을 분석 중입니다.", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(pending), }); }); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "피드백"); await expect(page.getByText("평가 대기", { exact: true })).toBeVisible(); await expect(page.getByText("평가 AI가 저장된 축어록을 분석 중입니다.")).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0); await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled(); }); 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, evaluationFailure: { code: "timeout", retryable: true, }, 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 openReviewTab(page, "피드백"); await expect(page.getByText("평가 실패")).toBeVisible(); await expect( page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."), ).toBeVisible(); await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0); await expect( page.getByText("평가 생성이 제한 시간 안에 끝나지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다."), ).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible(); await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled(); await page.getByRole("button", { name: "AI 평가 재시도" }).click(); await expect(page.getByText("평가 완료")).toBeVisible(); await expect(page.getByText("평가 실패")).toHaveCount(0); await expect(page.getByRole("button", { name: "검토 완료" })).toBeEnabled(); 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, evaluationFailure: { code: "timeout", retryable: true, }, 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("AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다."), ).toBeVisible(); await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0); await expect(page.getByText("평가 실패")).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled(); expect(reevaluateRequests).toBe(1); await expect.poll(() => reviewRequests).toBeGreaterThan(reviewRequestsBeforeRetry); }); test("does not offer a futile retry when the evaluation input is too large", async ({ page }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-failed-input-too-large"; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { const ready = filledReviewResponse(sessionId); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...ready, supervisorState: "평가 실패", summary: "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. 평가 입력 경로를 조정한 뒤 다시 생성해야 합니다.", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, evaluationFailure: { code: "prompt_too_large", retryable: false, }, teacherReview: { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }, }), }); }); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "피드백"); await expect(page.getByText("평가 실패")).toBeVisible(); await expect( page.getByText("평가 입력이 허용 크기를 넘어섰습니다. 같은 재시도 대신 입력 경로를 조정해야 합니다."), ).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0); }); test("offers recovery retry for a legacy Windows argv-limit evaluation", async ({ page }) => { await routeSupervisorCapableLearner(page); await routePrepostMeasures(page); const sessionId = "review-legacy-windows-argv-limit"; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { const ready = filledReviewResponse(sessionId); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...ready, supervisorState: "평가 실패", summary: "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.", rubric: [], goodMoments: [], growthPoints: [], degraded: true, reviewReady: false, evaluationFailure: { code: "legacy_argv_limit", retryable: true, }, teacherReview: { status: "pending", note: "", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }, }), }); }); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "피드백"); await expect( page.getByText( "이전 Windows 입력 한도에 걸린 평가입니다. 축어록은 보존됐으며 현재 입력 경로로 다시 시도할 수 있습니다.", ), ).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible(); }); 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 openReviewTab(page, "피드백"); 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.whiteSpace === "nowrap")).toBe(true); if ((page.viewportSize()?.width ?? 0) <= 720) { expect(filterMetrics.every((item) => item.height >= 44)).toBe(true); } else { expect(filterMetrics.every((item) => item.height <= 34)).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.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0); 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("감정 반영").first()).toBeVisible(); await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible(); await openReviewTab(page, "피드백"); 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("방어를 낮춘 감정 반영")).toBeVisible(); await expect(page.getByText("개입 전 준비도 확인")).toBeVisible(); await expect(page.locator(".sr-chart__svg")).toBeVisible(); const firstSessionChecklist = page.locator( ".sr-card--first-session-checklist", ); await expect(firstSessionChecklist).toContainText( "첫 회기 라포·개방질문 체크리스트", ); await expect(firstSessionChecklist).toContainText( "first-session-rapport-open-question.v1", ); await expect(firstSessionChecklist).toContainText("라포 형성 반영"); await expect(firstSessionChecklist).toContainText("한 초점 개방질문"); await expect(firstSessionChecklist.getByText("근거 확인")).toHaveCount(2); await expect(firstSessionChecklist).toContainText( "임상 평가나 성적 판정이 아닙니다.", ); const checklistEvidence = firstSessionChecklist.getByRole("button", { name: "라포 형성 반영 근거 2번 발화로 이동", }); await expect(checklistEvidence).toBeVisible(); const checklistEvidenceHeight = await checklistEvidence.evaluate((button) => Math.ceil(button.getBoundingClientRect().height), ); expect(checklistEvidenceHeight).toBeGreaterThanOrEqual(44); await checklistEvidence.click(); await expect(page.locator("#sr-panel-transcript")).toBeVisible(); await expect(page.locator(".sr-turn--active")).toContainText( "혼자서 버티고 있었다는 느낌", ); await openReviewTab(page, "워크시트"); await expect(page.getByText("사례개념화 워크시트")).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("renders role-safe counselor and client tokens without exposing mask syntax", async ({ page, }) => { await routeLearnerReviewUser(page); await routePrepostMeasures(page); const sessionId = "00000000-0000-4000-8000-000000000112"; const response = filledReviewResponse(sessionId); response.turns[0] = { ...response.turns[0], text: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.", }; response.turns[1] = { ...response.turns[1], text: "[COUNSELOR]가 [CLIENT]의 말을 천천히 되짚었습니다.", }; response.summary = "평가 AI는 [CLIENT]가 [ORG]에서 보인 반응과 [COUNSELOR]의 반영을 분석했습니다."; response.goodMoments[0] = { ...response.goodMoments[0], title: "[COUNSELOR]가 보인 강점", body: "[ORG] 장면에서 [CLIENT] 반응을 기다렸습니다.", }; response.growthPoints[0] = { ...response.growthPoints[0], title: "[NAME] 반응을 서두르지 않기", body: "[ORG] 장면에서 익명 인물의 반응을 한 번 더 확인합니다.", }; response.nextLine = "[CLIENT]의 말을 [ORG] 맥락에서 다시 확인할까요?"; response.clientFeedback = "[CLIENT] 이야기를 [ORG]에서 안전하게 다뤘어요."; if (!response.caseWorksheet) { throw new Error("filled review fixture must include a worksheet"); } response.caseWorksheet.sections[0].items[0].evidence = [ { turnId: "t1", speaker: "client", quote: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.", }, ]; await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(response), }); }); const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId)); await page.goto(`/learn/session/${sessionId}/review`); const reviewResponse = await reviewResponsePromise; await expectResponseOk(reviewResponse); const apiReview = await reviewResponse.json(); expect(apiReview.turns[0].text).toContain("[CLIENT]"); expect(apiReview.turns[1].text).toContain("[COUNSELOR]"); expect(apiReview.growthPoints[0].title).toContain("[NAME]"); const transcript = page.locator(".sr-card--transcript"); await expect(transcript).toContainText( "저는 내담자이고 소속 기관에서 상담받고 있어요.", ); await expect(transcript).toContainText( "상담자가 내담자의 말을 천천히 되짚었습니다.", ); await expect(transcript).not.toContainText("[NAME]"); await expect(transcript).not.toContainText("[COUNSELOR]"); await expect(transcript).not.toContainText("[CLIENT]"); const summary = page.locator(".sr-summary"); await expect(summary).toContainText( "평가 AI는 내담자가 소속 기관에서 보인 반응과 상담자의 반영을 분석했습니다.", ); await expect(summary).not.toContainText("[NAME]"); await expect(summary).not.toContainText("[ORG]"); await openReviewTab(page, "피드백"); const feedback = page.locator("#sr-panel-feedback"); await expect(feedback).toContainText("상담자가 보인 강점"); await expect(feedback).toContainText("소속 기관 장면에서 내담자 반응을 기다렸습니다."); await expect(feedback).toContainText("익명 인물 반응을 서두르지 않기"); await expect(feedback).toContainText( "내담자의 말을 소속 기관 맥락에서 다시 확인할까요?", ); await expect(feedback).not.toContainText("[NAME]"); await expect(feedback).not.toContainText("[COUNSELOR]"); await expect(feedback).not.toContainText("[CLIENT]"); await expect(feedback).not.toContainText("[ORG]"); await openReviewTab(page, "워크시트"); const worksheetPanel = page.locator("#sr-panel-worksheet"); const worksheetEvidence = worksheetPanel.locator(".sr-ws-evidence").filter({ hasText: "저는 내담자이고 소속 기관에서 상담받고 있어요.", }); await expect(worksheetEvidence).toContainText( "저는 내담자이고 소속 기관에서 상담받고 있어요.", ); await expect(worksheetPanel).not.toContainText("[NAME]"); await expect(worksheetPanel).not.toContainText("[CLIENT]"); await expect(worksheetPanel).not.toContainText("[ORG]"); }); 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-overview")).toBeVisible({ timeout: 15_000 }); await openReviewTab(page, "피드백"); 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("저장된 값 기준"); const prepostCard = page.locator(".sr-card--prepost"); await prepostInputs.nth(0).fill(""); await expect(prepostInputs.nth(0)).toHaveAttribute("aria-invalid", "true"); await expect(prepostCard.locator(".sr-prepost__actions")).toContainText("저장되지 않은 입력 있음"); await expect(prepostCard.locator(".sr-prepost__actions")).not.toContainText("저장된 값 기준"); await expect(prepostCard.getByRole("alert")).toContainText("기존 값을 비우려면 새 점수를 입력해 주세요."); await expect(prepostCard.getByRole("button", { name: "점수 저장" })).toBeDisabled(); }); test("keeps learner inputs and privacy controls while disabled AI feedback makes no derived requests", async ({ page, }) => { await routeLearnerReviewUser(page); await routePrepostMeasures(page); const sessionId = "feedback-disabled-review"; const base = filledReviewResponse(sessionId); const derivedRequests: string[] = []; const derivedRequestCounts = { rupture: 0, outcomeTrajectory: 0, deliberatePractice: 0, calibrationSupervisor: 0, multimodalDerived: 0, sessionEvaluation: 0, liveCoach: 0, share: 0, }; let calibrationInputReads = 0; let multimodalPrivacyReads = 0; page.on("request", (request) => { const url = new URL(request.url()); const path = url.pathname; if (path.includes(`/sessions/${sessionId}/ruptures`)) { derivedRequestCounts.rupture += 1; } if (path.includes(`/sessions/${sessionId}/outcome-trajectory`)) { derivedRequestCounts.outcomeTrajectory += 1; } if (path.endsWith("/practice/learners/me") || path.includes("/practice/attempts/")) { derivedRequestCounts.deliberatePractice += 1; } if ( path.includes("/internal/") && (path.includes("/calibration/") || path.includes("/transfer-suites")) || path.endsWith("/calibration/reviews") || /\/calibration\/learners\/(?!me$)/.test(path) ) { derivedRequestCounts.calibrationSupervisor += 1; } if ( url.pathname.endsWith(`/sessions/${sessionId}/multimodal-alliance`) && url.searchParams.get("include_derived") !== "false" ) { derivedRequestCounts.multimodalDerived += 1; } if (path.includes(`/eval/sessions/${sessionId}`)) { derivedRequestCounts.sessionEvaluation += 1; } if (path.includes(`/sessions/${sessionId}/live-coach`)) { derivedRequestCounts.liveCoach += 1; } if (path.endsWith(`/sessions/${sessionId}/share`)) { derivedRequestCounts.share += 1; } if (Object.values(derivedRequestCounts).some((count) => count > 0)) { derivedRequests.push(`${request.method()} ${url.pathname}${url.search}`); } }); await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...base, sessionNo: 2, learnerFeedbackEnabled: false, reviewReady: false, degraded: false, supervisorState: "피드백 비활성", summary: "관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. 회기 축어록과 학습자 입력은 그대로 보존됩니다.", caseWorksheet: { ...base.caseWorksheet, status: "saved_by_learner", generatedBy: "learner", savedAt: "2026-08-27T01:02:03Z", sections: [ { key: "learner-notes", title: "내 기록", items: [ { key: "presenting-complaint", label: "주호소", value: "학습자가 직접 저장한 원문", evidence: [], confidence: "none", emptyReason: null, }, ], }, ], limitations: ["학습자 작성"], }, }), }); }); await page.route("**/api/calibration/learners/me**", async (route) => { calibrationInputReads += 1; const url = new URL(route.request().url()); expect(url.searchParams.get("session_id")).toBe(sessionId); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ learner_id: "00000000-0000-0000-0000-000000000101", requested_view: "learner", clinical_claim_allowed: false, prediction_histories: [], calibration_assessments: [], transfer_suites: [], teacher_reviews: [], actual_executions: [], actual_transfer_assessments: [], }), }); }); await page.route( `**/api/sessions/${sessionId}/multimodal-alliance**`, async (route) => { const url = new URL(route.request().url()); if (url.pathname.includes("/raw-audio/")) { await route.fulfill({ status: 200, contentType: "audio/webm", body: "", }); return; } if (url.pathname.endsWith("/raw-audio")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [ { audio_asset_id: "00000000-0000-4000-8000-000000000901", session_id: sessionId, media_type: "audio/webm", byte_size: 8192, duration_ms: 12000, retained_until: "2026-09-26T01:02:03Z", created_at: "2026-08-27T01:02:03Z", }, ], }), }); return; } multimodalPrivacyReads += 1; expect(url.searchParams.get("include_derived")).toBe("false"); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, learner_id: "00000000-0000-0000-0000-000000000101", clinical_claim_allowed: false, consent_snapshots: [ { consent_snapshot_id: "00000000-0000-4000-8000-000000000902", sequence_no: 1, consent_status: "granted", retain_audio: true, retain_derived_features: false, transcript_retained: true, retention_days: 30, policy_version: "vignette.multimodal-consent.v1", reason_code: null, created_at: "2026-08-27T01:02:03Z", }, ], timelines: [], word_timestamps: [], voice_events: [], measurements: [], fusion_decisions: [], deletion_requests: [], }), }); }, ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [ { pulse_id: "00000000-0000-4000-8000-000000000903", checkpoint: "post", status: "ready", learner_locked_at: "2026-08-27T01:02:03Z", revealed_at: "2026-08-27T01:03:03Z", error_code: null, self_scores: { goal: 0.8, task: 0.6, bond: 1.0 }, measurements: [ { perspective: "independent_observer", rationale: "학습자에게 노출되면 안 되는 AI 비밀 근거", evidence: [{ text: "AI 비밀 근거" }], }, ], }, ], }), }); }); await page.goto(`/learn/session/${sessionId}/review`); await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("button", { name: "공유 URL 복사" })).toHaveCount(0); await expect(page.locator(".ap-card")).toContainText("저장한 내 치료 동맹 자기평가"); await expect(page.locator(".ap-card")).toContainText("목표80점"); await expect(page.locator(".ap-comparison")).toHaveCount(0); await expect(page.getByText("AI 비밀 근거", { exact: false })).toHaveCount(0); await openReviewTab(page, "피드백"); await expect( page.getByRole("heading", { name: "내가 관찰한 현재 상태를 직접 기록합니다", }), ).toBeVisible(); await expect( page.getByRole("heading", { name: "먼저 예측하고 잠그는 내 기록만 보존합니다", }), ).toBeVisible(); await expect( page.getByRole("heading", { name: "동의·보존·삭제 범위를 직접 관리합니다", }), ).toBeVisible(); await expect(page.getByText(/학습자 본인 계정에서만 접근 가능/)).toBeVisible(); await expect(page.locator(".rr-card, .dp-card")).toHaveCount(0); await expect( page.locator( ".ot-timeline-wrap, .ct-calibration, .ct-transfer, .mma-clock, .mma-axis-board", ), ).toHaveCount(0); await expect( page.locator( ".sr-card--chart, .sr-card--flow, .sr-card--rubric, .sr-card--first-session-checklist, .sr-feedback", ), ).toHaveCount(0); const privacyDelete = page.getByRole("button", { name: "선택 데이터 삭제 요청", }); await privacyDelete.scrollIntoViewIfNeeded(); const privacyDeleteHeight = await privacyDelete.evaluate((button) => Math.ceil(button.getBoundingClientRect().height), ); expect(privacyDeleteHeight).toBeGreaterThanOrEqual(44); await expectNoHorizontalOverflow(page); await openReviewTab(page, "워크시트"); await expect(page.locator(".sr-ws-input").first()).toHaveValue( "학습자가 직접 저장한 원문", ); await openReviewTab(page, "축어록"); await expect(page.locator(".sr-card--transcript")).toContainText( "다른 친구들이랑 비교할 때마다", ); await expectNoHorizontalOverflow(page); expect(calibrationInputReads).toBeGreaterThanOrEqual(1); expect(multimodalPrivacyReads).toBeGreaterThanOrEqual(1); expect(derivedRequestCounts).toEqual({ rupture: 0, outcomeTrajectory: 0, deliberatePractice: 0, calibrationSupervisor: 0, multimodalDerived: 0, sessionEvaluation: 0, liveCoach: 0, share: 0, }); expect(derivedRequests).toEqual([]); }); });