vignette/apps/web/e2e/uc-session-review-core.spec.ts
Yun Chan cf899b7f15 유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정
- 지원 티켓 작성 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)
2026-08-18 11:47:58 +09:00

801 lines
32 KiB
TypeScript

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