- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59)
927 lines
34 KiB
TypeScript
927 lines
34 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {}) {
|
|
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<string, unknown> = {},
|
|
) {
|
|
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<Record<string, unknown>>,
|
|
) {
|
|
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<string, unknown> = {},
|
|
) {
|
|
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<string, unknown> = {},
|
|
) {
|
|
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<Record<string, unknown>> = [],
|
|
) {
|
|
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<string, unknown>,
|
|
) {
|
|
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<Record<string, unknown>> = [];
|
|
await page.route(
|
|
`**/api/teacher/sessions/${sessionId}/review-status`,
|
|
(route: Route) => {
|
|
const body = route.request().postDataJSON() as Record<string, unknown>;
|
|
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<Record<string, unknown>> = [];
|
|
await page.route("**/api/calibration/reviews", (route: Route) => {
|
|
correctionPosts.push(
|
|
route.request().postDataJSON() as Record<string, unknown>,
|
|
);
|
|
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();
|
|
});
|
|
});
|