/** * uc-teacher-console.spec.ts — 교수자 콘솔·수퍼비전 여정 유스케이스 스펙. * * 목적: 교수자가 (1) 콘솔 큐에서 검토 대상을 고르고, (2) 수퍼비전 attention * 큐에서 위험 신호 우선순위를 따라 3클릭 이내로 학습자 근거까지 내려가며, * (3) 학습자 상세에서 측정 근거(적절성·라포·AI 평가 상태)를 확인하고, * (4) 교수자 회기 리뷰에서 읽기전용 경계를 지키면서 재평가 상태 제출과 * append-only 교정(정정 이력)을 남기는 실제 여정을 검증한다. * * 근거: apps/web/src/pages/Professor.tsx, SupervisionResearch.tsx, * SessionReview.tsx(+ session-review/CalibrationTransferCard.tsx)의 실제 UI 계약. * 모든 응답은 route fixture로 고정하며 실제 AI 엔진 턴 생성은 하지 않는다. * * 중복 회피: full-sweep-professor.spec.ts(가드·KPI·정렬 세그먼트·트렌드 차트· * 300행 가상화·단계 카드), teacher.spec.ts(대기 행 클릭→검토 완료 닫기), * supervision-research.spec.ts(교수/연구 렌즈 노출 경계), full-sweep-session-review * (워크시트 메모·반려), uc-session-review-core(AI 평가 재시도)는 다루지 않는다. */ import { expect, test, type Page, type Route } from "@playwright/test"; import { filledReviewResponse, emptyReviewResponse, } from "./session-review-fixture"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill( jsonRoute({ detail: "not part of this focused fixture" }, 404), ), ); } async function routeAuthedTeacher(page: Page) { await page.route("**/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "62000000-0000-0000-0000-000000000902", email: "teacher@hs.ac.kr", role: "teacher", display_name: "E2E Teacher", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["uc-tc-cohort"], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "E2E Teacher", self_introduction: "", avatar_url: "", }), ), ); } function dashboardFixture(overrides: Record = {}) { return { source: "database", cohort_label: "우촌 상담실습 코호트A", total_learners: 0, active_sessions: 0, ended_sessions: 0, learner_growth: [], safety_alerts: [], pending_reviews: [], recent_sessions: [], message: "검토할 실제 회기가 없습니다.", ...overrides, }; } function pendingReviewFixture(index: number, learnerId?: string) { const padded = String(index).padStart(2, "0"); return { session_id: `uc-tc-pending-${padded}`, learner_id: learnerId ?? `62000000-0000-0000-0000-0000000001${padded}`, learner_label: `검토 학습자 ${padded}`, persona_code: "P1", persona_name: "서연", session_no: index, status: "ended", stage: "정리", turn_count: 6 + index, learner_turn_count: 3, client_turn_count: 3, started_at: `2026-08-1${index}T07:00:00Z`, ended_at: `2026-08-1${index}T07:32:00Z`, review_status: "pending", review_note: null, reviewed_at: null, }; } function safetyAlertFixture(index: number) { return { id: `uc-tc-alert-${index}`, session_id: `uc-tc-crisis-${index}`, learner_id: `62000000-0000-0000-0000-0000000003${String(index).padStart(2, "0")}`, learner_label: `위기 학습자 ${index}`, persona_code: "P1", ko_risk_level: "높음", created_at: "2026-08-15T10:00:00Z", resource_title: "자살예방 상담전화", resource_number: "109", }; } function growthLearnerFixture( id: string, label: string, overrides: Record = {}, ) { return { learner_id: id, learner_label: label, sessions: 2, ended_sessions: 2, latest_at: "2026-08-12T09:30:00Z", first_score: 0.5, latest_score: 0.8, score_delta: 0.3, avg_score: 0.65, avg_rapport: 0.3, trend: "up", top_techniques: ["reflection"], points: [], ...overrides, }; } function growthPointFixture( learnerId: string, index: number, score: number, rapport: number, ) { return { session_id: `${learnerId}-session-${index}`, session_no: index, persona_code: "P1", stage: index > 1 ? "개입" : "탐색", started_at: `2026-08-1${index}T09:00:00Z`, ended_at: `2026-08-1${index}T09:30:00Z`, score, rapport, technique_count: index + 1, watch_count: 0, }; } function analysisSessionFixture( learnerId: string, label: string, index: number, ) { return { session_id: `${learnerId}-session-${index}`, learner_id: learnerId, learner_label: label, persona_code: "P1", persona_name: "서연", session_no: index, status: "ended", stage: index > 1 ? "개입" : "탐색", turn_count: 8 + index, learner_turn_count: 4 + index, client_turn_count: 4, started_at: `2026-08-1${index}T09:00:00Z`, ended_at: `2026-08-1${index}T09:30:00Z`, review_status: "pending", review_note: null, reviewed_at: null, }; } function learnerAnalysisFixture(learnerId: string, label: string) { const points = [ growthPointFixture(learnerId, 1, 0.5, 0.2), growthPointFixture(learnerId, 2, 0.8, 0.4), ]; const sessions = [ analysisSessionFixture(learnerId, label, 1), analysisSessionFixture(learnerId, label, 2), ]; return { source: "database", learner_id: learnerId, learner_label: label, total_sessions: sessions.length, active_sessions: 0, ended_sessions: sessions.length, pending_reviews: sessions.length, closed_reviews: 0, summary: growthLearnerFixture(learnerId, label, { points }), points, stage_breakdown: [ { stage: "라포", sessions: 0, turns: 0 }, { stage: "탐색", sessions: 1, turns: 34 }, { stage: "개입", sessions: 1, turns: 42 }, { stage: "정리", sessions: 0, turns: 0 }, ], sessions, message: `${label} 전체 회기 ${sessions.length}건`, }; } function attentionItemFixture( position: number, learnerRef: string, primarySignal: string, drilldownRoutes: string[], ) { return { item_id: `uc-tc-attention-${position}`, snapshot_id: "uc-tc-snapshot-001", learner_id: `62000000-0000-0000-0000-0000000005${String(position).padStart(2, "0")}`, learner_ref: learnerRef, cohort_id: "uc-tc-cohort", queue_position: position, primary_signal: primarySignal, oldest_active_sequence: position + 3, drilldown_routes: drilldownRoutes, evidence_pointer_ids: [`pointer-${learnerRef}-1`, `pointer-${learnerRef}-2`], created_at: "2026-08-15T03:00:00Z", }; } async function routeSupervision( page: Page, attentionItems: Array>, ) { await page.route("**/api/supervision-research/supervision-view", (route) => route.fulfill( jsonRoute({ attention_items: attentionItems, curriculum_gaps: [], clinical_claim_allowed: false, }), ), ); await page.route("**/api/supervision-research/research-view", (route) => route.fulfill( jsonRoute({ calibration_dataset: [], drift_reports: [], phase3_manifests: [], raw_transcript_included: false, clinical_claim_allowed: false, }), ), ); } async function routeConsole( page: Page, dashboard: Record = {}, ) { await page.route("**/api/teacher/dashboard", (route) => route.fulfill(jsonRoute(dashboardFixture(dashboard))), ); await page.route("**/api/personas/review", (route) => route.fulfill(jsonRoute([])), ); } /** 교수자 회기 리뷰 화면 응답(리뷰 본문 + 동맹 펄스 빈 목록)을 고정한다. */ async function routeTeacherSessionReview( page: Page, sessionId: string, overrides: Record = {}, ) { await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill(jsonRoute({ ...filledReviewResponse(sessionId), ...overrides })), ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); } const ASSESSMENT_SNAPSHOT_ID = "62000000-0000-0000-0000-00000000aa01"; /** 교수자 교정 패널이 읽는 캘리브레이션 read model (판정 1건 + 기존 교정 이력). */ function calibrationReadModelFixture( sessionId: string, teacherReviews: Array> = [], ) { const competencyId = "competency.empathic_reflection"; return { learner_id: "62000000-0000-0000-0000-000000000111", requested_view: "supervisor", clinical_claim_allowed: false, prediction_histories: [], transfer_suites: [], teacher_reviews: teacherReviews, calibration_assessments: [ { assessment_snapshot_id: ASSESSMENT_SNAPSHOT_ID, competency_id: competencyId, session_id: sessionId, snapshot_no: 1, created_at: "2026-08-15T09:00:00Z", evidence_turn_ids: [], instrument_id: "vignette.calibration-assessment", instrument_version: "1.0.0", model_run_id: "62000000-0000-0000-0000-00000000bb01", prescription_id: "62000000-0000-0000-0000-00000000cc01", source_observation_ids: [], assessment_payload: { bias: "overconfident", competency_id: competencyId, counterevidence: [], excluded_block_ids: [], improvement: "insufficient_evidence", mean_absolute_error: 0.18, error_interval: { lower: 0.1, upper: 0.3 }, pair_count: 3, pairs: [], }, prescription_payload: { bias: "overconfident", competency_id: competencyId, completion_evidence: ["counterevidence_listed"], instruction_ko: "다음 예측 전에 실패 가능 근거를 먼저 적는다", practice_mode: "counterevidence_forecast", }, }, ], }; } function teacherReviewItemFixture(reviewNo: number, reason: string) { return { review_id: `62000000-0000-0000-0000-00000000dd0${reviewNo}`, review_no: reviewNo, review_reason: reason, disposition: "corrected", target_kind: "calibration_assessment", target_id: ASSESSMENT_SNAPSHOT_ID, correction_payload: {}, counterevidence: [], evidence_turn_ids: [], created_at: `2026-08-1${reviewNo}T09:00:00Z`, created_by_role: "instructor", created_by_uid: "62000000-0000-0000-0000-000000000902", submission_id: `62000000-0000-0000-0000-00000000ee0${reviewNo}`, supersedes_review_id: null, }; } /** 빈 캘리브레이션 read model — 교수자 화면의 읽기전용 경계 확인용. */ function emptyCalibrationReadModelFixture() { return { learner_id: "62000000-0000-0000-0000-000000000111", requested_view: "supervisor", clinical_claim_allowed: false, prediction_histories: [], transfer_suites: [], teacher_reviews: [], calibration_assessments: [], }; } /** 교수자 리뷰 화면에서 캘리브레이션 카드가 요구하는 read model 경로를 고정한다. */ async function routeSupervisorCalibration( page: Page, readModel: Record, ) { await page.route("**/api/calibration/learners/*", (route) => route.fulfill(jsonRoute(readModel)), ); } test.describe("uc-teacher-console: 교수 콘솔 큐", () => { // usecase: 교수자가 아무 기록도 없는 콘솔에 들어와 네 큐 모두의 빈 상태 안내를 읽는다 test("빈 콘솔은 검토·위기·페르소나·최근 큐마다 빈 상태 안내를 보여준다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page); await page.goto("/teach"); await expect( page.getByRole("heading", { name: "검토할 실제 회기가 없습니다." }), ).toBeVisible(); await expect( page.getByText("종료된 회기를 검토 완료로 닫으면 이 목록에서 사라집니다."), ).toBeVisible(); await expect(page.getByText("현재 위기 알림 없음")).toBeVisible(); await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible(); await expect(page.getByText("세션 로그 없음")).toBeVisible(); await expect( page.getByRole("button", { name: "첫 검토 열기" }), ).toHaveCount(0); }); // usecase: 교수자가 대기 리뷰 수를 확인하고 "첫 검토 열기"로 첫 대기 회기의 검토 화면을 연다 test("첫 검토 열기 버튼이 첫 번째 대기 회기의 교수자 검토 화면을 연다", async ({ page, }) => { const sessionId = "uc-tc-pending-01"; await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page, { ended_sessions: 2, pending_reviews: [pendingReviewFixture(1), pendingReviewFixture(2)], message: "2건의 리뷰가 대기 중입니다.", }); await routeTeacherSessionReview(page, sessionId); await page.goto("/teach"); await expect( page.getByRole("heading", { name: "2건의 리뷰가 대기 중입니다." }), ).toBeVisible(); await page.getByRole("button", { name: "첫 검토 열기" }).click(); await expect(page).toHaveURL( new RegExp(`/teach/session/${sessionId}/review$`), ); await expect( page.getByText("교수자 검토 화면", { exact: true }), ).toBeVisible(); }); // usecase: 교수자가 109 안전 확인 큐에서 위기 학습자·위험도·안내 번호를 확인한다 test("109 안전 확인 큐가 위기 학습자와 위험도, 109 리소스를 표시한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page, { safety_alerts: [safetyAlertFixture(1), safetyAlertFixture(2)], }); await page.goto("/teach"); const safetySection = page.locator(".pf-section--safety"); await expect( safetySection.getByRole("heading", { name: "109 안전 확인 큐" }), ).toBeVisible(); await expect(safetySection.getByText("2건", { exact: true })).toBeVisible(); const firstAlert = safetySection.locator(".pf-alert").first(); await expect(firstAlert).toContainText("위기 학습자 1"); await expect(firstAlert).toContainText("위험도 높음"); await expect(firstAlert).toContainText("자살예방 상담전화"); await expect(firstAlert).toContainText("109"); await expect(firstAlert).toContainText("uc-tc-crisis-1"); }); // usecase: 교수자가 검토 큐 요약에서 담당 코호트 맥락과 대기·누적 수치를 확인한다 test("검토 큐 요약이 코호트 라벨 툴팁과 리뷰 대기·누적 회기 수를 보여준다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page, { active_sessions: 1, ended_sessions: 3, pending_reviews: [pendingReviewFixture(1), pendingReviewFixture(2)], message: "2건의 리뷰가 대기 중입니다.", }); await page.goto("/teach"); const triage = page.locator(".pf-triage"); await expect(triage).toBeVisible(); await expect( triage.locator('span[title="우촌 상담실습 코호트A"]'), ).toContainText("실제 기록"); const meta = triage.locator(".pf-triage__meta"); await expect(meta).toContainText("리뷰 대기"); await expect(meta.locator("span").filter({ hasText: "리뷰 대기" }).locator("b")).toHaveText("2"); await expect(meta.locator("span").filter({ hasText: "누적 회기" }).locator("b")).toHaveText("4"); }); }); test.describe("uc-teacher-console: 수퍼비전 attention 큐", () => { // usecase: 교수자가 attention 큐에서 위험 신호가 우선순위 순서로 정렬된 것을 확인한다 test("attention 큐는 응답 순서와 무관하게 queue_position 순으로 위험 신호를 정렬한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); // 의도적으로 뒤섞인 순서(3→1→2)로 내려도 화면은 우선순위 순으로 보여야 한다. await routeSupervision(page, [ attentionItemFixture(3, "learner-stagnation", "growth_stagnation", [ "/teach/analysis?learner=uc-learner-stagnation", ]), attentionItemFixture(1, "learner-deterioration", "deterioration", [ "/teach/analysis?learner=uc-learner-deterioration", ]), attentionItemFixture(2, "learner-safety", "safety_boundary", [ "/teach/analysis?learner=uc-learner-safety", ]), ]); await page.goto("/teach/supervision"); const queueNames = page.locator(".g6-queue__item .g6-queue__copy strong"); await expect(queueNames).toHaveText([ "learner-deterioration", "learner-safety", "learner-stagnation", ]); // 위험(악화+안전 경계) 2명, 정체 1명, 미해결 관계 사건 0명 집계. await expect(page.getByLabel("위험 2명")).toBeVisible(); await expect(page.getByLabel("정체 1명")).toBeVisible(); await expect(page.getByLabel("미해결 관계 사건 0명")).toBeVisible(); // 최우선 항목이 자동 선택되어 근거 원장이 먼저 열린다. await expect( page.getByRole("button", { name: /learner-deterioration/ }), ).toHaveAttribute("aria-pressed", "true"); }); // usecase: 교수자가 attention 큐 항목을 고르고 드릴다운 링크로 3클릭 이내에 학습자 상세에 도달한다 test("attention 항목에서 두 번의 클릭으로 학습자 상세 분석까지 내려간다", async ({ page, }) => { const learnerId = "uc-risk-learner"; await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeSupervision(page, [ attentionItemFixture(1, "learner-calm", "growth_stagnation", [ "/teach/analysis?learner=uc-calm-learner", ]), attentionItemFixture(2, "learner-risk", "deterioration", [ `/teach/analysis?learner=${learnerId}`, ]), ]); await routeConsole(page, { total_learners: 1, learner_growth: [growthLearnerFixture(learnerId, "위험 학습자")], }); await page.route("**/api/teacher/learners/*/analysis", (route) => route.fulfill(jsonRoute(learnerAnalysisFixture(learnerId, "위험 학습자"))), ); await page.goto("/teach/supervision"); // 클릭 1: 큐에서 위험 학습자 항목 선택. await page.getByRole("button", { name: /learner-risk/ }).click(); const drilldown = page.locator(".g6-drilldown a"); await expect(drilldown).toHaveCount(1); // 클릭 2: 원장 근거 드릴다운 링크로 학습자 상세 진입 (계약 상한 3클릭 이내). await drilldown.first().click(); await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerId}`)); await expect( page.locator('[data-learner-analysis-panel="true"]'), ).toBeVisible(); await expect( page.locator(".pf-depth-strip .is-current"), ).toContainText("학습자 상세"); }); }); test.describe("uc-teacher-console: 학습자 상세 측정 근거", () => { // usecase: 교수자가 학습자 상세의 페르소나별 회기를 펼쳐 회기별 측정 근거(적절성·라포·평가 상태)를 확인한다 test("페르소나별 회기를 펼치면 회기별 적절성·라포·평가 상태 근거가 표시된다", async ({ page, }) => { const learnerId = "uc-learner-evidence"; await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page, { total_learners: 1, learner_growth: [growthLearnerFixture(learnerId, "근거 학습자")], }); await page.route("**/api/teacher/learners/*/analysis", (route) => route.fulfill(jsonRoute(learnerAnalysisFixture(learnerId, "근거 학습자"))), ); await page.goto(`/teach/analysis?learner=${learnerId}`); const panel = page.locator('[data-learner-analysis-panel="true"]'); await expect(panel).toBeVisible(); await expect( panel.getByRole("tab", { name: /페르소나별 회기/ }), ).toHaveAttribute("aria-selected", "true"); const group = panel.locator('[data-learner-persona-group="true"]'); await expect(group).toHaveCount(1); // 그룹 요약에도 평균 적절성(65%) 등 집계 근거가 노출된다. await expect(group).toContainText("65%"); await group .getByRole("button", { name: "P1 서연 회기 펼치기" }) .click(); const sessions = panel.locator('[data-learner-session-row="true"]'); await expect(sessions).toHaveCount(2); // 1회기: score 0.5 → 50%, rapport 0.2 → 60%, 평가·검토 상태 배지. await expect(sessions.nth(0)).toContainText("50%"); await expect(sessions.nth(0)).toContainText("60%"); await expect(sessions.nth(0)).toContainText("기록 대기"); await expect(sessions.nth(0)).toContainText("검토 대기"); // 2회기: score 0.8 → 80%, rapport 0.4 → 70%. await expect(sessions.nth(1)).toContainText("80%"); await expect(sessions.nth(1)).toContainText("70%"); await group .getByRole("button", { name: "P1 서연 회기 접기" }) .click(); await expect( panel.locator('[data-learner-persona-sessions="true"]'), ).toHaveCount(0); }); // usecase: 교수자가 학습자 테이블을 기법 태그로 검색해 코호트 안에서 대상 학습자만 남긴다 test("학습자 테이블 검색이 기법 태그로 행을 좁히고 지우면 전체가 복원된다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeConsole(page, { total_learners: 2, learner_growth: [ growthLearnerFixture("uc-learner-a", "반영 학습자", { top_techniques: ["reflection"], }), growthLearnerFixture("uc-learner-b", "질문 학습자", { top_techniques: ["socratic questioning"], }), ], }); await page.goto("/teach/analysis"); const rows = page.locator('[data-learner-overview-row="true"]'); await expect(rows).toHaveCount(2); await page.getByLabel("학습자 검색").fill("socratic"); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText("질문 학습자"); await page.getByLabel("학습자 검색").fill(""); await expect(rows).toHaveCount(2); }); }); test.describe("uc-teacher-console: 교수자 회기 리뷰", () => { const sessionId = "uc-tc-review-01"; async function routeReviewConsoleContext(page: Page) { // 캘리브레이션 카드의 교수자 조회는 dashboard에서 session_id→learner_id를 // 찾으므로, 대기 목록의 session_id가 리뷰 화면의 세션과 일치해야 한다. await routeConsole(page, { ended_sessions: 1, pending_reviews: [ { ...pendingReviewFixture(1, "62000000-0000-0000-0000-000000000111"), session_id: sessionId, }, ], }); } // usecase: 교수자가 리뷰 탭을 키보드(화살표·Home·End)만으로 순회한다 test("리뷰 탭이 ArrowRight·End·Home 키보드 조작으로 전환된다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); await routeTeacherSessionReview(page, sessionId); await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture()); await page.goto(`/teach/session/${sessionId}/review`); const transcriptTab = page.getByRole("tab", { name: "축어록" }); const insightsTab = page.getByRole("tab", { name: "피드백" }); const worksheetTab = page.getByRole("tab", { name: "워크시트" }); await expect(transcriptTab).toHaveAttribute("aria-selected", "true"); await transcriptTab.focus(); await page.keyboard.press("ArrowRight"); await expect(insightsTab).toHaveAttribute("aria-selected", "true"); await expect(insightsTab).toBeFocused(); await page.keyboard.press("End"); await expect(worksheetTab).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#sr-panel-worksheet")).toBeVisible(); await page.keyboard.press("Home"); await expect(transcriptTab).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#sr-panel-transcript")).toBeVisible(); }); // usecase: 교수자가 검토 화면에서 학습자 전용 조작 없이 읽기와 교정 경계만 확인한다 test("교수자 검토 화면은 공유·자기예측 등 학습자 전용 조작을 노출하지 않는다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); await routeTeacherSessionReview(page, sessionId); await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture()); await page.goto(`/teach/session/${sessionId}/review`); await expect( page.getByText("교수자 검토 화면", { exact: true }), ).toBeVisible(); await expect(page.getByRole("button", { name: "교수 콘솔로" })).toBeVisible(); // 학습자 전용 공유 조작은 교수자 화면에 없다. await expect(page.getByRole("button", { name: /공유 URL/ })).toHaveCount(0); await page.getByRole("tab", { name: "피드백" }).click(); // 캘리브레이션 카드: 교수자는 학습자 예측을 대신 만들 수 없다. await expect( page.getByText("학습자가 잠근 예측이 아직 없어"), ).toBeVisible(); await expect(page.getByText("첫 자기예측 기록")).toHaveCount(0); await expect(page.getByText("자기예측 revision 추가")).toHaveCount(0); // 교수자 권한은 읽기와 append-only 교정으로 명시된다. await expect( page.getByRole("heading", { name: "읽기와 교정만 가능" }), ).toBeVisible(); await expect( page.getByText("교정할 역량별 판정이 아직 없어."), ).toBeVisible(); }); // usecase: 교수자가 검토 메모를 적고 메모 저장으로 검토 중(viewed) 상태를 제출한다 test("검토 메모 저장이 viewed 상태를 PUT하고 상태 라벨이 검토 중으로 바뀐다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); await routeTeacherSessionReview(page, sessionId, { teacherReview: { status: "pending", note: "", worksheetNote: "", worksheetStatus: "pending", reviewedAt: null, reviewerId: null, updatedAt: null, worksheetReviewedAt: null, }, }); await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture()); const statusPuts: Array> = []; await page.route( `**/api/teacher/sessions/${sessionId}/review-status`, (route: Route) => { const body = route.request().postDataJSON() as Record; statusPuts.push(body); return route.fulfill( jsonRoute({ session_id: sessionId, status: body.status, note: body.note, reviewer_id: "62000000-0000-0000-0000-000000000902", reviewed_at: null, updated_at: "2026-08-15T10:00:00Z", worksheet_note: body.worksheet_note ?? "", worksheet_status: "pending", worksheet_reviewed_at: null, }), ); }, ); await page.goto(`/teach/session/${sessionId}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const teacherCard = page.locator(".sr-card--teacher-review"); await expect(teacherCard.locator("b").first()).toHaveText("검토 대기"); await page .getByLabel("검토 메모") .fill("다음 회기에서 비교 장면을 더 좁혀 확인"); await page.getByRole("button", { name: "메모 저장" }).click(); await expect(teacherCard.locator("b").first()).toHaveText("검토 중"); expect(statusPuts).toHaveLength(1); expect(statusPuts[0].status).toBe("viewed"); expect(statusPuts[0].note).toBe("다음 회기에서 비교 장면을 더 좁혀 확인"); }); // usecase: 교수자가 평가 미준비 회기에서 검토 완료·워크시트 판정이 잠긴 것을 확인한다 test("평가 미준비 회기는 검토 완료와 워크시트 판정을 비활성화한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); // 종료됐지만 축어록·평가가 없는 회기: reviewReady=false, 워크시트 학습자 저장본 없음. await page.route(`**/api/sessions/${sessionId}/review`, (route) => route.fulfill(jsonRoute(emptyReviewResponse(sessionId))), ); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) => route.fulfill(jsonRoute({ items: [] })), ); await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture()); await page.goto(`/teach/session/${sessionId}/review`); await page.getByRole("tab", { name: "피드백" }).click(); await expect( page.getByText("종료 회기만 검토 완료로 닫을 수 있습니다."), ).toBeVisible(); await expect(page.getByRole("button", { name: "메모 저장" })).toBeEnabled(); await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled(); // 학습자 저장본이 없으면 워크시트 판정 세 버튼 모두 잠긴다. await expect(page.getByRole("button", { name: "승인" })).toBeDisabled(); await expect(page.getByRole("button", { name: "수정요청" })).toBeDisabled(); await expect(page.getByRole("button", { name: "반려" })).toBeDisabled(); }); // usecase: 교수자가 원판정을 덮어쓰지 않는 교정 revision을 제출하고 append-only 이력을 확인한다 test("교수자 교정이 append-only로 제출되고 기존 정정 이력이 revision 목록으로 보인다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); await routeTeacherSessionReview(page, sessionId); await routeSupervisorCalibration( page, calibrationReadModelFixture(sessionId, [ teacherReviewItemFixture(1, "첫 교정: 반영 판정 근거 보강"), teacherReviewItemFixture(2, "둘째 교정: 과신 판정 재검토"), ]), ); const correctionPosts: Array> = []; await page.route("**/api/calibration/reviews", (route: Route) => { correctionPosts.push( route.request().postDataJSON() as Record, ); return route.fulfill(jsonRoute({ recorded: true })); }); await page.goto(`/teach/session/${sessionId}/review`); await page.getByRole("tab", { name: "피드백" }).click(); // 판정 스냅샷(과신 경향)이 교정 대상으로 표시된다. await expect(page.getByText("과신 경향", { exact: true })).toBeVisible(); const correction = page.locator(".ct-correction"); await expect( correction.getByRole("heading", { name: "읽기와 교정만 가능" }), ).toBeVisible(); await expect(correction.locator("select")).toHaveValue( `calibration_assessment:${ASSESSMENT_SNAPSHOT_ID}`, ); // 기존 append-only 정정 이력 2건이 revision 번호와 함께 노출된다. const history = correction.locator(".ct-review-history"); await expect(history.locator("summary")).toHaveText( "기존 교수자 교정 2개 보기", ); await history.locator("summary").click(); await expect(history.locator("li")).toHaveCount(2); await expect(history.locator("li").nth(0)).toContainText( "revision 1 · 첫 교정: 반영 판정 근거 보강", ); await expect(history.locator("li").nth(1)).toContainText( "revision 2 · 둘째 교정: 과신 판정 재검토", ); await page .getByLabel("교정 이유 (발화 원문 제외)") .fill("반영 판정에서 침묵 허용 맥락을 반영해 다시 읽어야 함"); await correction .getByRole("button", { name: "교정 revision 추가" }) .click(); await expect( page.getByText("교수자 교정을 append-only 원장에 추가했어."), ).toBeVisible(); expect(correctionPosts).toHaveLength(1); expect(correctionPosts[0].disposition).toBe("corrected"); expect(correctionPosts[0].target_kind).toBe("calibration_assessment"); expect(correctionPosts[0].target_id).toBe(ASSESSMENT_SNAPSHOT_ID); expect( (correctionPosts[0].correction_payload as { teacher_note: string }) .teacher_note, ).toBe("반영 판정에서 침묵 허용 맥락을 반영해 다시 읽어야 함"); }); // usecase: 교수자가 이미 검토 완료로 닫힌 회기를 다시 열면 저장된 완료 이력이 그대로 보인다 test("검토 완료된 회기는 완료 시각·메모를 보존하고 재완료를 막는다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedTeacher(page); await routeReviewConsoleContext(page); await routeTeacherSessionReview(page, sessionId, { teacherReview: { status: "closed", note: "감정 반영 흐름 양호, 다음은 준비도 확인", worksheetNote: "", worksheetStatus: "pending", reviewedAt: "2026-08-10T09:00:00Z", reviewerId: "62000000-0000-0000-0000-000000000902", updatedAt: "2026-08-10T09:00:00Z", worksheetReviewedAt: null, }, }); await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture()); await page.goto(`/teach/session/${sessionId}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const teacherCard = page.locator(".sr-card--teacher-review"); await expect(teacherCard.locator("b").first()).toHaveText("검토 완료"); await expect( page.getByText("완료 시각 2026-08-10T09:00:00Z"), ).toBeVisible(); await expect(page.getByLabel("검토 메모")).toHaveValue( "감정 반영 흐름 양호, 다음은 준비도 확인", ); await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled(); }); });