유스케이스 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)
This commit is contained in:
parent
4771b97c3a
commit
cf899b7f15
37 changed files with 11989 additions and 67 deletions
801
apps/web/e2e/uc-session-review-core.spec.ts
Normal file
801
apps/web/e2e/uc-session-review-core.spec.ts
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
/**
|
||||
* 유스케이스 스펙 — 회기 리뷰 핵심 여정 (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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue