전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
555
apps/web/e2e/full-sweep-session-review.spec.ts
Normal file
555
apps/web/e2e/full-sweep-session-review.spec.ts
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
/**
|
||||
* 전수 순회(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: "내 발화만" });
|
||||
|
||||
await expect(allChip).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(mineChip).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator(".sr-turn")).toHaveCount(6);
|
||||
await expect(page.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(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeHidden();
|
||||
await expect(
|
||||
page.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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue