vignette/apps/web/e2e/full-sweep-session-review.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

562 lines
23 KiB
TypeScript

/**
* 전수 순회(2026-07-27) — 회기 리뷰(session-review) 영역의 "신규 spec 필요" 항목 검증.
*
* docs/ops/e2e-full-sweep-2026-07-27.md §4에서 기존 spec이 덮지 않던 기능을
* route fixture(리뷰 응답·공유·교수자 검수 PUT) + 실 dev-login 조합으로 고정해 검증한다.
* 실제 AI 엔진 턴 생성은 하지 않는다.
*
* 대상 checklist id:
* - session-review-guard-require-auth / session-review-guard-pending-onboarding
* - session-review-error-state
* - session-review-btn-audio / session-review-btn-pdf
* - session-review-btn-share / session-review-share-note
* - session-review-transcript-filter / session-review-turn-jump
* - session-review-phase-flow / session-review-rubric-display
* - session-review-good-growth-points / session-review-worksheet-evidence-jump
* - session-review-teacher-worksheet-note / session-review-teacher-worksheet-reject
*/
import { expect, test, type Page } from "@playwright/test";
import {
filledReviewResponse,
routeEmptySessionReview,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { signInAsLearner, signInAsTeacher } from "./support";
/** 리뷰 상단 가로 탭 전환(기존 session-review.spec.ts 헬퍼와 동일한 계약). */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 10_000 });
await tabs.locator("button", { hasText: label }).click();
}
function routeAuthMe(
page: Page,
overrides: {
role?: "learner" | "teacher";
account_status?: string;
onboarding_completed_at?: number | null;
} = {},
) {
return page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-000000000101",
email: "learner@hs.ac.kr",
role: overrides.role ?? "learner",
display_name: "E2E Learner",
admin_access: false,
super_admin: false,
account_status: overrides.account_status ?? "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at:
overrides.onboarding_completed_at === undefined
? 1782820001
: overrides.onboarding_completed_at,
nickname: "E2E Learner",
self_introduction: "",
avatar_url: "",
}),
});
});
}
test.describe("full-sweep session review", () => {
// checklist: session-review-guard-require-auth
test("redirects unauthenticated and role-mismatched users away from review routes", async ({
page,
}) => {
// 미인증 → /login (state.from 회수용 리다이렉트).
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/login$/);
// learner가 교수자 리뷰 경로에 오면 자기 역할 홈(/learn)으로 replace.
await signInAsLearner(page);
await page.goto("/teach/session/guard-check/review");
await expect(page).toHaveURL(/\/learn$/);
// teacher가 학습자 리뷰 경로에 오면 자기 역할 홈(/teach)으로 replace.
await signInAsTeacher(page);
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/teach$/);
});
// checklist: session-review-guard-pending-onboarding
test("recovers pending and onboarding-incomplete users from review routes", async ({
page,
}) => {
await signInAsLearner(page);
// 미승인 계정은 어느 리뷰 경로에서든 /pending으로 회수된다.
await routeAuthMe(page, { account_status: "pending" });
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/pending$/);
// 온보딩 미완료(승인됨)는 /onboarding으로 회수된다.
await page.unroute("**/api/auth/me");
await routeAuthMe(page, { onboarding_completed_at: null });
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/onboarding$/);
});
// checklist: session-review-error-state
test("shows the error card with the failure message when the review load fails", async ({
page,
}) => {
await signInAsLearner(page);
const sessionId = "review-load-error";
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "review backend down" }),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.getByText("리뷰를 표시할 수 없습니다")).toBeVisible();
await expect(page.getByText("API 500: review backend down")).toBeVisible();
});
// checklist: session-review-btn-audio, session-review-btn-pdf
test("opens audio and pdf export urls in a new tab only when provided", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-audio-pdf";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
const audioButton = page.getByRole("button", { name: "오디오 다시 듣기" });
const pdfButton = page.getByRole("button", { name: "PDF 내보내기" });
await expect(audioButton).toBeEnabled();
await expect(pdfButton).toBeEnabled();
const [audioPopup] = await Promise.all([
page.context().waitForEvent("page"),
audioButton.click(),
]);
await expect
.poll(() => audioPopup.url())
.toContain("/mock/reviews/filled-review-visual.mp3");
await audioPopup.close();
const [pdfPopup] = await Promise.all([
page.context().waitForEvent("page"),
pdfButton.click(),
]);
await expect
.poll(() => pdfPopup.url())
.toContain("/mock/reviews/filled-review-visual.pdf");
await pdfPopup.close();
// URL이 없는 회기에서는 두 버튼 모두 비활성화된다.
const emptyId = "full-sweep-audio-pdf-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
});
// checklist: session-review-btn-share, session-review-share-note
test("creates a share url, flips the button label, and shows the url note", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-share-success";
await routeFilledSessionReview(page, sessionId);
const shareUrl = "https://vignette.example.test/share/full-sweep-share-success";
let shareRequests = 0;
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
shareRequests += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
shareUrl,
title: "회기 리뷰 공유",
description: "테스트 공유 링크",
imageUrl: "https://vignette.example.test/share/full-sweep.png",
createdAt: "2026-07-27T00:00:00Z",
}),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
const shareButton = page.getByRole("button", { name: "공유 URL 복사" });
await expect(shareButton).toBeEnabled();
await shareButton.click();
await expect(page.getByRole("button", { name: "공유 URL 복사됨" })).toBeVisible();
const note = page.locator(".sr-share-note");
await expect(note).toBeVisible();
await expect(note).toContainText(shareUrl);
await expect(note).not.toHaveClass(/sr-share-note--error/);
expect(shareRequests).toBe(1);
});
// checklist: session-review-btn-share, session-review-share-note
test("keeps the share button usable and shows the error note when share creation fails", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-share-error";
await routeFilledSessionReview(page, sessionId);
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "share backend unavailable" }),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await page.getByRole("button", { name: "공유 URL 복사" }).click();
const note = page.locator(".sr-share-note");
await expect(note).toBeVisible();
await expect(note).toHaveClass(/sr-share-note--error/);
await expect(note).toContainText("API 503: share backend unavailable");
// 실패 후 라벨은 '복사됨'으로 넘어가지 않고 재시도 가능해야 한다.
await expect(page.getByRole("button", { name: "공유 URL 복사" })).toBeEnabled();
});
// checklist: session-review-transcript-filter
test("toggles the transcript between all turns and learner-only turns", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-transcript-filter";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
const filters = page.locator(".sr-tx__filters");
const allChip = filters.getByRole("button", { name: "전체" });
const mineChip = filters.getByRole("button", { name: "내 발화만" });
// 동맹 펄스 카드(AlliancePulseCard)의 근거 장면 선택기가 같은 발화를 인용하므로
// 발화 본문 확인은 축어록 탭패널 안으로 한정한다(strict mode 중복 방지).
const transcript = page.locator("#sr-panel-transcript");
await expect(allChip).toHaveAttribute("aria-pressed", "true");
await expect(mineChip).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".sr-turn")).toHaveCount(6);
await expect(
transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }),
).toBeVisible();
await mineChip.click();
await expect(mineChip).toHaveAttribute("aria-pressed", "true");
await expect(allChip).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".sr-turn")).toHaveCount(3);
await expect(
transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }),
).toBeHidden();
await expect(
transcript.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }),
).toBeVisible();
// 축어록이 없으면 두 필터 칩 모두 비활성화된다.
const emptyId = "full-sweep-transcript-filter-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await expect(page.locator(".sr-tx__filters").getByRole("button", { name: "전체" })).toBeDisabled();
await expect(
page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }),
).toBeDisabled();
});
// checklist: session-review-turn-jump
test("jumps to the referenced turn and auto-clears the learner-only filter", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-turn-jump";
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);
// 개선점 '비교 장면 좁히기'는 내담자 발화 t3(12:41)로 점프한다.
await openReviewTab(page, "피드백");
await page.getByRole("button", { name: "12:41 발화로 이동" }).click();
await openReviewTab(page, "축어록");
// 내담자 발화 점프이므로 '내 발화만' 필터가 자동 해제되어야 한다.
await expect(
page.locator(".sr-tx__filters").getByRole("button", { name: "전체" }),
).toHaveAttribute("aria-pressed", "true");
const activeTurn = page.locator(".sr-turn--active");
await expect(activeTurn).toHaveCount(1);
await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서");
});
// checklist: session-review-phase-flow
test("renders the phase flow bar with proportional tooltips and its empty state", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-phase-flow";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const phases = page.locator(".sr-phasebar__track .sr-phase");
await expect(phases).toHaveCount(4);
await expect(phases.nth(0)).toContainText("라포");
await expect(phases.nth(0)).toHaveAttribute("title", "라포 · 22%");
await expect(phases.nth(1)).toHaveAttribute("title", "탐색 · 34%");
await expect(phases.nth(2)).toHaveAttribute("title", "개입 · 26%");
await expect(phases.nth(3)).toHaveAttribute("title", "정리 · 18%");
const axis = page.locator(".sr-phasebar__axis");
await expect(axis).toContainText("0:00");
await expect(axis).toContainText("32:14");
// 단계 데이터가 없으면 빈 상태 문구를 보여준다.
const emptyId = "full-sweep-phase-flow-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--flow")).toContainText("흐름 데이터 없음");
});
// 결정 D7: 감정 밸런스 타임라인 — fixture 포인트로 차트 path 렌더 확인
test("renders the valence timeline chart paths from fixture points", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-valence-chart";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const chartCard = page.locator(".sr-card--chart");
await expect(chartCard.locator(".sr-chart")).toBeVisible();
// 실선(내담자)·점선(상담자 기준선) path 둘 다 다점 곡선(d="M… C…")으로 그려진다.
const paths = chartCard.locator(".sr-chart__svg path");
await expect(paths).toHaveCount(2);
const dValues = await paths.evaluateAll((els) =>
els.map((el) => el.getAttribute("d") ?? ""),
);
for (const d of dValues) {
expect(d).toMatch(/^M/);
expect(d).toContain("C");
}
// 시간축 라벨은 fixture valenceAxis 5개를 그대로 사용한다.
await expect(chartCard.locator(".sr-chart__xaxis span")).toHaveCount(5);
// 포인트가 없으면 차트 대신 빈 상태 카드가 노출된다.
const emptyId = "full-sweep-valence-chart-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--chart")).toContainText("감정 타임라인 대기");
await expect(page.locator(".sr-card--chart .sr-chart")).toHaveCount(0);
});
// checklist: session-review-rubric-display
test("renders rubric frequency bars with quality badges and the pending state", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-rubric";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const rubricCard = page.locator(".sr-card--rubric");
await expect(rubricCard.locator(".sr-rubric__row")).toHaveCount(4);
const reflectRow = rubricCard.locator(".sr-rubric__row", { hasText: "감정 반영" });
await expect(reflectRow.locator(".sr-rubric__qual")).toHaveText(/적절/);
await expect(reflectRow).toContainText("정서 확인 · 6회");
const paceRow = rubricCard.locator(".sr-rubric__row", { hasText: "개입 속도" });
await expect(paceRow.locator(".sr-rubric__qual")).toHaveText(/살펴보기/);
const reflectBar = page.getByRole("progressbar", { name: "감정 반영 사용 빈도" });
await expect(reflectBar).toBeVisible();
await expect(reflectBar).toHaveAttribute("aria-valuenow", "82");
// 평가가 없으면 대기 상태를 보여준다.
const emptyId = "full-sweep-rubric-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--rubric")).toContainText("평가 대기");
await expect(
page.locator(".sr-card--rubric").locator(".sr-rubric__row"),
).toHaveCount(0);
});
// checklist: session-review-good-growth-points
test("lists good moments and growth points with jump anchors capped at three", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-good-growth";
const response = filledReviewResponse(sessionId);
response.growthPoints = [
...response.growthPoints,
{ title: "세 번째 개선점", body: "표시 한도 검증용 항목 3.", jumpTo: null },
{ title: "네 번째 개선점", body: "최대 3개 초과분은 표시되지 않아야 한다.", jumpTo: null },
];
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const goodCard = page.locator(".sr-card--good");
await expect(goodCard).toContainText("방어를 낮춘 감정 반영");
await expect(goodCard).toContainText("정서 정상화");
await expect(goodCard.getByRole("button", { name: "05:04 발화로 이동" })).toBeVisible();
await expect(goodCard.getByRole("button", { name: "16:05 발화로 이동" })).toBeVisible();
const growthCard = page.locator(".sr-card--growth");
await expect(growthCard).toContainText("개입 전 준비도 확인");
await expect(growthCard).toContainText("비교 장면 좁히기");
await expect(growthCard).toContainText("세 번째 개선점");
await expect(growthCard.locator(".sr-point")).toHaveCount(3);
await expect(growthCard.getByText("네 번째 개선점")).toHaveCount(0);
await expect(growthCard.getByRole("button", { name: "24:18 발화로 이동" })).toBeVisible();
});
// checklist: session-review-worksheet-evidence-jump
test("jumps from a worksheet evidence quote to the transcript turn", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-ws-evidence";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
const evidenceButton = page
.getByRole("button", { name: "내담자 근거 발화로 이동" })
.first();
await expect(evidenceButton).toContainText("다른 친구들이랑 비교할 때마다");
await evidenceButton.click();
await openReviewTab(page, "축어록");
const activeTurn = page.locator(".sr-turn--active");
await expect(activeTurn).toHaveCount(1);
await expect(activeTurn).toContainText("계속 제가 이상한 사람 같았어요.");
});
// checklist: session-review-teacher-worksheet-note, session-review-teacher-worksheet-reject
test("saves the teacher worksheet rejection together with the review note", async ({
page,
}) => {
await signInAsTeacher(page);
const sessionId = "full-sweep-ws-reject";
const response = filledReviewResponse(sessionId);
if (!response.caseWorksheet) throw new Error("fixture must include a worksheet");
response.caseWorksheet.status = "saved_by_learner";
response.teacherReview = {
status: "pending",
note: "",
reviewedAt: null,
reviewerId: null,
updatedAt: null,
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
});
const worksheetNote = "핵심 정서 근거를 보강해 주세요.";
let releaseSave: (() => void) | null = null;
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
let savedBody: Record<string, unknown> | null = null;
await page.route(
`**/api/teacher/sessions/${sessionId}/review-status`,
async (route) => {
savedBody = route.request().postDataJSON() as Record<string, unknown>;
await saveGate;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
status: "viewed",
note: String(savedBody.note ?? ""),
reviewer_id: "00000000-0000-0000-0000-000000000202",
reviewed_at: null,
updated_at: "2026-07-27 10:00",
worksheet_status: "rejected",
worksheet_note: String(savedBody.worksheet_note ?? ""),
worksheet_reviewed_at: "2026-07-27 10:00",
}),
});
},
);
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const worksheetPanel = page.locator(".sr-teacher-review__worksheet");
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
"검수 대기",
);
await page
.getByPlaceholder("수정요청이나 반려 사유를 간단히 남깁니다.")
.fill(worksheetNote);
const approveButton = worksheetPanel.getByRole("button", { name: "승인" });
const changesButton = worksheetPanel.getByRole("button", { name: "수정요청" });
const rejectButton = worksheetPanel.getByRole("button", { name: "반려" });
await expect(approveButton).toBeEnabled();
await expect(rejectButton).toBeEnabled();
await rejectButton.click();
// 저장 중에는 세 결정 버튼이 모두 비활성화된다(응답은 saveGate로 붙잡아 둔 상태).
await expect(worksheetPanel.getByRole("button", { name: "저장 중" })).toBeVisible();
await expect(approveButton).toBeDisabled();
await expect(changesButton).toBeDisabled();
releaseSave?.();
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
"반려",
);
await expect(worksheetPanel).toContainText("워크시트 검수 시각 2026-07-27 10:00");
expect(savedBody).not.toBeNull();
expect(savedBody!["worksheet_status"]).toBe("rejected");
expect(savedBody!["worksheet_note"]).toBe(worksheetNote);
expect(savedBody!["status"]).toBe("viewed");
});
});