/** * uc-review-rupture-repair.spec.ts — 균열·수선(rupture/repair) 리뷰 여정 유스케이스 스펙. * * 목적: 학습자·교수자가 세션 리뷰의 "피드백" 탭에서 균열과 수선 원장(RuptureRepairCard)을 * 읽고 행동하는 실제 여정을 검증한다 — 여러 균열 정렬, missed/partial/resolved 상태 뱃지, * 빠른 경고 vs 깊은 재조정 복구 분기 비교, 장면 재생(해당 턴 점프), 교수자 정정의 * append-only 표시, 균열 없음 빈 상태, 오류 후 재시도. * * 근거: apps/web/src/pages/session-review/RuptureRepairCard.tsx 의 실제 한국어 카피· * role/label 계약과 apps/web/src/pages/session-review/ruptureRepairApi.ts 의 wire DTO. * fixture 형태는 e2e/rupture-repair.spec.ts 를 따르되, 그 스펙이 이미 커버한 시나리오 * (학습자 전체 뷰, 404 학습자 빈 상태, 정정 제출·idempotent 재시도)는 반복하지 않는다. * * 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음. */ import { expect, test, type Page, type Route } from "@playwright/test"; import type { RuptureEpisode, RuptureObservation, RuptureReconciliation, RuptureRepairReadModel, RuptureSafetyReference, } from "../src/pages/session-review/ruptureRepairApi"; import { FILLED_REVIEW_SESSION_ID, filledReviewResponse, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type ReviewRole = "learner" | "teacher"; const TURN_UUIDS = [ "40000000-0000-4000-8000-000000000001", "40000000-0000-4000-8000-000000000002", "40000000-0000-4000-8000-000000000003", "40000000-0000-4000-8000-000000000004", "40000000-0000-4000-8000-000000000005", "40000000-0000-4000-8000-000000000006", ]; const UNKNOWN_TURN_UUID = "49999999-0000-4000-8000-000000000099"; const MODEL_RUN_ID = "43000000-0000-4000-8000-000000000001"; async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } let episodeSeq = 0; function nextEpisodeId(): string { episodeSeq += 1; return `41000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`; } let observationSeq = 0; function makeObservation( episodeId: string, overrides: Partial = {}, ): RuptureObservation { observationSeq += 1; return { observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`, episode_id: episodeId, sequence_no: 1, event_kind: "rupture.detected", from_state: null, to_state: "onset", rupture_type: "withdrawal", source_kind: "observed_runtime", perspective: "runtime_observation", ai_view: "evaluator", confidence: 0.8, uncertainty: 0.2, evidence_turn_ids: [TURN_UUIDS[1]], counterevidence: [], model_run_id: null, supersedes_observation_id: null, correction_reason: null, created_at: "2026-08-06T09:10:00Z", ...overrides, }; } function makeReconciliation( episodeId: string, overrides: Partial = {}, ): RuptureReconciliation { return { revision_id: `45000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`, episode_id: episodeId, revision_no: 1, supersedes_revision_id: null, fast_warning_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`, deep_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`, fast_warning_id: "fast-warning-9", provisional_status: "missed", deep_status: "partial", disposition: "superseded_partial", uncertainty: 0.22, evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]], counterevidence: [], model_run_id: MODEL_RUN_ID, created_at: "2026-08-06T09:16:00Z", ...overrides, }; } function makeSafetyReference( episodeId: string, overrides: Partial = {}, ): RuptureSafetyReference { return { episode_id: episodeId, safety_event_id: 71, turn_id: TURN_UUIDS[4], ko_risk_level: 2, escalated: true, created_at: "2026-08-06T09:12:00Z", ...overrides, }; } function makeEpisode(overrides: Partial = {}): RuptureEpisode { const episodeId = (overrides.episode_id as string | undefined) ?? nextEpisodeId(); return { episode_id: episodeId, session_id: FILLED_REVIEW_SESSION_ID, case_id: "44000000-0000-4000-8000-000000000001", learner_id: "44000000-0000-4000-8000-000000000002", episode_key: "withdrawal-after-premature-advice", created_at: "2026-08-06T09:10:00Z", rupture_type: "withdrawal", current_status: "partial", status_source: "lifecycle_event", observations: [makeObservation(episodeId)], reconciliation_revisions: [], safety_references: [], ...overrides, ...(overrides.episode_id ? {} : { episode_id: episodeId }), }; } function readModel( role: ReviewRole, episodes: RuptureEpisode[], ): RuptureRepairReadModel { return { session_id: FILLED_REVIEW_SESSION_ID, requested_view: role === "teacher" ? "supervisor" : "counselor", clinical_claim_allowed: false, episodes, }; } async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => fulfillJson(route, { detail: "not part of this focused fixture" }, 404), ); } async function routeReviewUser(page: Page, role: ReviewRole) { await page.route("**/api/auth/me", (route) => fulfillJson(route, { user_id: role === "teacher" ? "00000000-0000-0000-0000-000000000202" : "00000000-0000-0000-0000-000000000101", email: `${role}@hs.ac.kr`, role, display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["e2e-hanshin"], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner", self_introduction: "", avatar_url: "", }), ); } async function routeReviewPage(page: Page, role: ReviewRole) { const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID); review.turns = review.turns.map((turn, index) => ({ ...turn, turn_id: TURN_UUIDS[index] ?? null, })); if (role === "teacher") { review.teacherReview = { status: "viewed", note: "", reviewedAt: null, reviewerId: "00000000-0000-0000-0000-000000000202", worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }; } await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) => fulfillJson(route, review), ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [] }), ); await routePrepostMeasures(page); } async function prepareReview(page: Page, role: ReviewRole) { await routeUnmockedApi(page); await routeReviewUser(page, role); await routeReviewPage(page, role); } async function routeRuptures( page: Page, handler: (route: Route) => Promise, ) { await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, handler, ); } async function openFeedback(page: Page, role: ReviewRole) { const root = role === "teacher" ? "/teach/session" : "/learn/session"; await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); return page.locator(".rr-card"); } function tripleEpisodes(): RuptureEpisode[] { return [ makeEpisode({ episode_key: "withdrawal-early", rupture_type: "withdrawal", current_status: "missed", }), makeEpisode({ episode_key: "confrontation-mid", rupture_type: "confrontation", current_status: "partial", }), makeEpisode({ episode_key: "goal-mismatch-late", rupture_type: "goal_mismatch", current_status: "resolved", }), ]; } test.describe("UC 균열·수선 리뷰 여정", () => { test.beforeEach(() => { episodeSeq = 0; observationSeq = 0; }); // usecase: 학습자가 피드백 탭을 열어 여러 균열 장면이 원장에 온 순서 그대로 나열된 것을 본다 test("여러 균열 에피소드가 fixture 순서대로 나열된다", async ({ page }) => { await prepareReview(page, "learner"); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", tripleEpisodes())), ); const card = await openFeedback(page, "learner"); const episodes = card.locator(".rr-episode"); await expect(episodes).toHaveCount(3); await expect(episodes.nth(0).getByRole("heading", { name: "철수형 균열" })).toBeVisible(); await expect(episodes.nth(1).getByRole("heading", { name: "대립형 균열" })).toBeVisible(); await expect(episodes.nth(2).getByRole("heading", { name: "목표 불일치" })).toBeVisible(); await expect(episodes.nth(0)).toContainText("withdrawal-early"); await expect(episodes.nth(1)).toContainText("confrontation-mid"); await expect(episodes.nth(2)).toContainText("goal-mismatch-late"); await expectNoHorizontalOverflow(page); }); // usecase: 학습자가 장면마다 붙은 상태 뱃지로 수선 놓침·부분 수선·수선 확인을 구분해 읽는다 test("missed·partial·resolved 상태 뱃지를 장면별로 구분해 보여준다", async ({ page }) => { await prepareReview(page, "learner"); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", tripleEpisodes())), ); const card = await openFeedback(page, "learner"); const episodes = card.locator(".rr-episode"); await expect( episodes.nth(0).locator(".rr-episode__badges").getByText("수선 놓침", { exact: true }), ).toBeVisible(); await expect( episodes.nth(1).locator(".rr-episode__badges").getByText("부분 수선", { exact: true }), ).toBeVisible(); await expect( episodes.nth(2).locator(".rr-episode__badges").getByText("수선 확인", { exact: true }), ).toBeVisible(); }); // usecase: 학습자가 아직 유형·상태 판정이 없는 장면을 열어 판정 대기 상태임을 확인한다 test("유형·상태 미판정 에피소드는 판정 대기로 표시된다", async ({ page }) => { await prepareReview(page, "learner"); const pending = makeEpisode({ rupture_type: null, current_status: null, observations: [], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [pending])), ); const card = await openFeedback(page, "learner"); await expect(card.getByRole("heading", { name: "유형 판정 대기" })).toBeVisible(); await expect( card.locator(".rr-episode__badges").getByText("상태 대기", { exact: true }), ).toBeVisible(); await expect(card.locator(".rr-current")).toContainText("판정 대기"); }); // usecase: 학습자가 현재 상태 요약에서 관찰 이력 출처와 불확실성 미기록(자료 없음)을 읽는다 test("관찰 이력 출처 요약에서 불확실성 미기록은 자료 없음으로 보여준다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, current_status: "recognized", status_source: "lifecycle_event", observations: [ makeObservation(episodeId, { event_kind: "rupture.recognized", to_state: "recognized", uncertainty: null, confidence: null, }), ], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); const current = card.locator(".rr-current"); await expect(current.getByText("균열 인식", { exact: true })).toBeVisible(); await expect(current.getByText("자료 없음", { exact: true })).toBeVisible(); await expect(current.getByText("관찰 이력", { exact: true })).toBeVisible(); }); // usecase: 학습자가 복구 분기 비교 뷰에서 빠른 경고 판정과 깊은 재조정 판정을 나란히 대조한다 test("복구 분기 비교 뷰가 빠른 경고와 깊은 재조정 판정을 나란히 보여준다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, current_status: "resolved", status_source: "deep_reconciliation", observations: [makeObservation(episodeId)], reconciliation_revisions: [ makeReconciliation(episodeId, { provisional_status: "missed", deep_status: "resolved", disposition: "superseded_resolved", fast_warning_id: "fast-warning-9", }), ], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처"); await expect(rail.getByText("빠른 경고", { exact: true })).toBeVisible(); await expect(rail).toContainText("수선 놓침 · fast-warning-9"); await expect(rail.getByText("깊은 재조정", { exact: true })).toBeVisible(); await expect(rail).toContainText("수선 확인으로 갱신 · 수선 확인"); await expect(rail.locator("code")).toContainText("run 43000000…000001"); }); // usecase: 학습자가 기각된 빠른 경고를 열어 경고 기각 처분과 판정 제외 상태를 확인한다 test("기각된 빠른 경고는 경고 기각 처분으로 표시된다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, current_status: "not_applicable", status_source: "deep_reconciliation", observations: [makeObservation(episodeId)], reconciliation_revisions: [ makeReconciliation(episodeId, { provisional_status: "onset", deep_status: "not_applicable", disposition: "dismissed", }), ], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); await expect( card.locator(".rr-episode__badges").getByText("판정 제외", { exact: true }), ).toBeVisible(); const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처"); await expect(rail).toContainText("경고 기각 · 판정 제외"); }); // usecase: 학습자가 안전 원장의 근거 발화 버튼으로 해당 장면(턴)으로 점프해 다시 본다 test("안전 원장 근거 발화 버튼으로 축어록 해당 턴에 점프한다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, observations: [makeObservation(episodeId)], safety_references: [makeSafetyReference(episodeId, { turn_id: TURN_UUIDS[4] })], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); const safety = card.locator(".rr-safety"); await expect(safety.getByText("안전 사건 #71")).toBeVisible(); await expect(safety.getByText("상향 확인", { exact: true })).toBeVisible(); await safety.getByRole("button", { name: "근거 발화로 이동" }).click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( "aria-selected", "true", ); await expect(page.locator(".sr-turn--active")).toHaveCount(1); }); // usecase: 학습자가 축어록과 매칭되지 않는 근거 UUID를 축약된 코드로 확인한다 test("축어록에 없는 근거 UUID는 축약 코드로 표시된다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, observations: [ makeObservation(episodeId, { evidence_turn_ids: [UNKNOWN_TURN_UUID], }), ], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); const evidence = card.locator(".rr-evidence-list"); await expect(evidence.locator("code")).toHaveText("49999999…000099"); await expect( evidence.getByRole("button", { name: /발화로 이동/ }), ).toHaveCount(0); }); // usecase: 학습자가 교수자 정정이 반영된 장면에서 사람 판정이 최신임과 정정 이유를 읽는다 test("교수자 정정이 append-only로 사람 판정 최신 표시와 이유를 보여준다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const first = makeObservation(episodeId, { sequence_no: 1 }); const correction = makeObservation(episodeId, { sequence_no: 2, event_kind: "human.corrected", from_state: "partial", to_state: "resolved", source_kind: "human_rated", perspective: "supervisor_human", ai_view: "supervisor", confidence: null, uncertainty: 0.1, evidence_turn_ids: [TURN_UUIDS[3]], supersedes_observation_id: first.observation_id, correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.", created_at: "2026-08-06T09:18:00Z", }); const episode = makeEpisode({ episode_id: episodeId, current_status: "resolved", status_source: "human_correction", observations: [first, correction], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible(); await expect( card.getByText("후속 반응 근거를 반영해 수선 확인으로 정정했습니다."), ).toBeVisible(); await expect( card.locator(".rr-episode__badges").getByText("사람 판정", { exact: true }), ).toBeVisible(); await expect( card.locator(".rr-episode__badges").getByText("수선 확인", { exact: true }), ).toBeVisible(); }); // usecase: 교수자가 정정 폼을 열어 기존 관찰을 고치지 않고 새 기록을 추가한다는 안내와 대상 관찰을 확인한다 test("정정 폼이 보존형(append-only) 안내와 대상 관찰 ID를 보여준다", async ({ page }) => { await prepareReview(page, "teacher"); const episodeId = nextEpisodeId(); const observation = makeObservation(episodeId, { observation_id: "42000000-0000-4000-8000-000000000777", evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]], }); const episode = makeEpisode({ episode_id: episodeId, current_status: "partial", observations: [observation], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("teacher", [episode])), ); const card = await openFeedback(page, "teacher"); await card.getByText("사람 판정으로 정정", { exact: true }).click(); const correction = card.locator(".rr-correction"); await expect( correction.getByRole("heading", { name: "최신 관찰을 보존형으로 정정" }), ).toBeVisible(); await expect( correction.getByText("기존 관찰은 수정하지 않고, 그 관찰을 대체하는 새 기록을 추가합니다."), ).toBeVisible(); await expect( correction.locator(".rr-correction__intro code"), ).toHaveText("42000000…000777"); }); // usecase: 교수자가 정정 이유를 쓰기 전에는 제출할 수 없고, 폼 기본값이 현재 판정·근거로 채워진 것을 본다 test("정정 폼은 현재 판정·근거로 프리필되고 이유 없이는 제출이 막힌다", async ({ page }) => { await prepareReview(page, "teacher"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, current_status: "partial", observations: [ makeObservation(episodeId, { evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]], }), ], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("teacher", [episode])), ); const card = await openFeedback(page, "teacher"); await card.getByText("사람 판정으로 정정", { exact: true }).click(); const form = card.locator(".rr-correction form"); await expect(form.getByLabel("정정 상태")).toHaveValue("partial"); await expect( form.locator(".rr-correction__evidence input[type=checkbox]:checked"), ).toHaveCount(2); const submitButton = form.getByRole("button", { name: "정정 기록 추가" }); await expect(submitButton).toBeDisabled(); await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다."); await expect(submitButton).toBeEnabled(); }); // usecase: 학습자가 균열이 하나도 기록되지 않은 회기의 피드백 탭에서 차분한 빈 상태 안내를 본다 test("에피소드 빈 배열이면 학습자용 빈 상태 안내를 보여준다", async ({ page }) => { await prepareReview(page, "learner"); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [])), ); const card = await openFeedback(page, "learner"); await expect( card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }), ).toBeVisible(); await expect( card.getByText( "회기에서 관계 균열과 수선 근거가 확인되면, 다시 연습할 장면을 여기서 준비할 수 있습니다.", ), ).toBeVisible(); await expectNoHorizontalOverflow(page); }); // usecase: 교수자가 원장이 아직 없는 회기(404)에서 역할에 맞는 빈 상태 안내를 본다 test("교수자 404 빈 상태는 역할 허용 관찰 안내 문구를 보여준다", async ({ page }) => { await prepareReview(page, "teacher"); await routeRuptures(page, (route) => fulfillJson(route, { detail: "rupture ledger not found" }, 404), ); const card = await openFeedback(page, "teacher"); await expect( card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }), ).toBeVisible(); await expect( card.getByText( "역할에 허용된 균열·수선 관찰이 생기면 근거와 재조정 이력을 여기서 검토할 수 있습니다.", ), ).toBeVisible(); }); // usecase: 학습자가 원장 로딩 실패를 만나면 오류 카드에서 다시 불러오기를 눌러 복구한다 test("원장 로딩 오류 후 다시 불러오기로 복구한다", async ({ page }) => { await prepareReview(page, "learner"); // StrictMode 이중 마운트에도 결정론적이도록, 테스트가 명시적으로 복구 시점을 제어한다. let healthy = false; let attempts = 0; await routeRuptures(page, async (route) => { attempts += 1; if (!healthy) { await fulfillJson(route, { detail: "temporary ledger failure" }, 500); return; } await fulfillJson(route, readModel("learner", [makeEpisode()])); }); const card = await openFeedback(page, "learner"); await expect( card.getByRole("heading", { name: "관계 장면 원장을 불러오지 못했습니다" }), ).toBeVisible(); healthy = true; await card.getByRole("button", { name: "다시 불러오기" }).click(); await expect( card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" }), ).toBeVisible(); expect(attempts).toBeGreaterThanOrEqual(2); }); // usecase: 학습자가 근거·반대 근거·안전 사건이 비어 있는 장면에서 각 영역의 빈 안내 문구를 읽는다 test("근거·반대 근거·안전 사건이 없으면 각 영역의 빈 안내를 보여준다", async ({ page }) => { await prepareReview(page, "learner"); const episodeId = nextEpisodeId(); const episode = makeEpisode({ episode_id: episodeId, observations: [ makeObservation(episodeId, { evidence_turn_ids: [], counterevidence: [], }), ], safety_references: [], }); await routeRuptures(page, (route) => fulfillJson(route, readModel("learner", [episode])), ); const card = await openFeedback(page, "learner"); await expect(card.getByText("연결된 발화 근거가 없습니다.")).toBeVisible(); await expect(card.getByText("현재 원장에 기록된 반대 근거가 없습니다.")).toBeVisible(); await expect(card.getByText("이 에피소드에 연결된 안전 사건이 없습니다.")).toBeVisible(); await expect( card.locator(".rr-safety").getByText("연결 없음", { exact: true }), ).toBeVisible(); }); });