import { expect, test, type Page, type Route } from "@playwright/test"; import type { RuptureEpisode, RuptureObservation, RuptureRepairReadModel, } 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 = [ "30000000-0000-4000-8000-000000000001", "30000000-0000-4000-8000-000000000002", "30000000-0000-4000-8000-000000000003", "30000000-0000-4000-8000-000000000004", "30000000-0000-4000-8000-000000000005", "30000000-0000-4000-8000-000000000006", ]; const EPISODE_ID = "31000000-0000-4000-8000-000000000001"; const OBSERVATION_ONE_ID = "32000000-0000-4000-8000-000000000001"; const OBSERVATION_TWO_ID = "32000000-0000-4000-8000-000000000002"; const CORRECTION_ID = "32000000-0000-4000-8000-000000000003"; async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } function observations(): RuptureObservation[] { return [ { observation_id: OBSERVATION_ONE_ID, episode_id: EPISODE_ID, 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.81, uncertainty: 0.19, 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", }, { observation_id: OBSERVATION_TWO_ID, episode_id: EPISODE_ID, sequence_no: 2, event_kind: "repair.partial", from_state: "repair_attempted", to_state: "partial", rupture_type: "withdrawal", source_kind: "model_inferred", perspective: "independent_observer", ai_view: "evaluator", confidence: 0.78, uncertainty: 0.22, evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]], counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."], model_run_id: "33000000-0000-4000-8000-000000000001", supersedes_observation_id: null, correction_reason: null, created_at: "2026-08-06T09:14:00Z", }, ]; } function episode(corrected = false): RuptureEpisode { const baseObservations = observations(); const correction: RuptureObservation = { observation_id: CORRECTION_ID, episode_id: EPISODE_ID, sequence_no: 3, event_kind: "human.corrected", from_state: "partial", to_state: "resolved", rupture_type: "withdrawal", source_kind: "human_rated", perspective: "supervisor_human", ai_view: "supervisor", confidence: null, uncertainty: 0.1, evidence_turn_ids: [TURN_UUIDS[3]], counterevidence: ["내담자의 후속 반응이 안정적으로 이어졌습니다."], model_run_id: null, supersedes_observation_id: OBSERVATION_TWO_ID, correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.", created_at: "2026-08-06T09:18:00Z", }; return { episode_id: EPISODE_ID, session_id: FILLED_REVIEW_SESSION_ID, case_id: "34000000-0000-4000-8000-000000000001", learner_id: "34000000-0000-4000-8000-000000000002", episode_key: "withdrawal-after-premature-advice", created_at: "2026-08-06T09:10:00Z", rupture_type: "withdrawal", current_status: corrected ? "resolved" : "partial", status_source: corrected ? "human_correction" : "deep_reconciliation", observations: corrected ? [...baseObservations, correction] : baseObservations, reconciliation_revisions: [ { revision_id: "35000000-0000-4000-8000-000000000001", episode_id: EPISODE_ID, revision_no: 1, supersedes_revision_id: null, fast_warning_observation_id: OBSERVATION_ONE_ID, deep_observation_id: OBSERVATION_TWO_ID, fast_warning_id: "fast-warning-17", 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: "33000000-0000-4000-8000-000000000001", created_at: "2026-08-06T09:16:00Z", }, ], safety_references: [ { episode_id: EPISODE_ID, safety_event_id: 71, turn_id: TURN_UUIDS[4], ko_risk_level: 2, escalated: true, created_at: "2026-08-06T09:12:00Z", }, ], }; } function readModel(role: ReviewRole, corrected = false): RuptureRepairReadModel { return { session_id: FILLED_REVIEW_SESSION_ID, requested_view: role === "teacher" ? "supervisor" : "counselor", clinical_claim_allowed: false, episodes: [episode(corrected)], }; } 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 page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`, (route) => fulfillJson(route, { detail: "not found" }, 404), ); await routePrepostMeasures(page); } async function prepareReview(page: Page, role: ReviewRole) { await routeReviewUser(page, role); await routeReviewPage(page, role); } 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"); } test.describe("G3 균열과 수선 원장", () => { test("학습자에게 현재 판정, 출처, 근거, 안전 원장과 로컬 연습 준비를 보여준다", async ({ page, }, testInfo) => { await prepareReview(page, "learner"); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) => fulfillJson(route, readModel("learner")), ); const card = await openFeedback(page, "learner"); await expect(card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" })).toBeVisible(); await expect(card.getByText("학습자 역할 보기", { exact: true })).toBeVisible(); await expect(card.getByText("철수형 균열", { exact: true })).toBeVisible(); await expect(card.getByText("부분 수선", { exact: true }).first()).toBeVisible(); await expect(card.getByText("불확실성")).toBeVisible(); await expect(card.getByText("22%", { exact: true })).toBeVisible(); await expect(card.getByText("빠른 경고", { exact: true })).toBeVisible(); await expect( card.getByLabel("빠른 경고와 깊은 재조정 출처").getByText("깊은 재조정", { exact: true }), ).toBeVisible(); await expect(card.getByText("부분 수선으로 갱신", { exact: false })).toBeVisible(); await expect(card.getByRole("heading", { name: "현재 판정 근거" })).toBeVisible(); await expect(card.getByRole("heading", { name: "반대 근거" })).toBeVisible(); await expect(card.getByRole("heading", { name: "안전 원장" })).toBeVisible(); await expect(card.getByText("균열·수선 판정과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible(); await expect(card.getByText(/총점|평균/)).toHaveCount(0); await expect(card.getByText("사람 판정으로 정정")).toHaveCount(0); const practice = card.locator(".rr-practice"); const firstItem = practice.getByLabel("균열이 시작된 발화를 다시 확인하기"); await firstItem.check(); await expect(firstItem).toBeChecked(); await page.reload(); await page.getByRole("tab", { name: "피드백" }).click(); await expect(page.locator(".rr-practice").getByLabel("균열이 시작된 발화를 다시 확인하기")).toBeChecked(); const refreshedCard = page.locator(".rr-card"); await refreshedCard.getByRole("button", { name: /발화로 이동/ }).first().click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute("aria-selected", "true"); await expect(page.locator(".sr-turn--active")).toHaveCount(1); await page.getByRole("tab", { name: "피드백" }).click(); await refreshedCard.locator(".rr-provenance").screenshot({ path: testInfo.outputPath(`rupture-provenance-${testInfo.project.name}.png`), animations: "disabled", }); await refreshedCard.locator(".rr-evidence-grid").screenshot({ path: testInfo.outputPath(`rupture-evidence-${testInfo.project.name}.png`), animations: "disabled", }); await refreshedCard.locator(".rr-practice").screenshot({ path: testInfo.outputPath(`rupture-practice-${testInfo.project.name}.png`), animations: "disabled", }); await expectNoHorizontalOverflow(page); }); test("404와 역할별 빈 원장은 차분한 빈 상태로 처리한다", async ({ page }) => { await prepareReview(page, "learner"); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) => fulfillJson(route, { detail: "rupture ledger not found" }, 404), ); const card = await openFeedback(page, "learner"); await expect(card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" })).toBeVisible(); await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0); await expectNoHorizontalOverflow(page); }); test("교수자는 최신 관찰을 supersede하고 성공 뒤 read model을 다시 불러온다", async ({ page, }, testInfo) => { await prepareReview(page, "teacher"); let corrected = false; let getCount = 0; const submissions: Array> = []; await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, async (route) => { getCount += 1; await fulfillJson(route, readModel("teacher", corrected)); }); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`, async (route) => { submissions.push(route.request().postDataJSON() as Record); corrected = true; await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201); }, ); const card = await openFeedback(page, "teacher"); await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible(); await expect(card.locator(".rr-practice")).toHaveCount(0); await card.getByText("사람 판정으로 정정", { exact: true }).click(); const form = card.locator(".rr-correction form"); await form.getByLabel("정정 상태").selectOption("resolved"); await form.getByLabel("정정 이유").fill("후속 반응이 안정적으로 이어진 근거를 반영합니다."); await form.getByRole("button", { name: "정정 기록 추가" }).click(); await expect(form.getByText("사람 판정을 새 관찰로 추가하고 최신 원장을 다시 불러왔습니다.")).toBeVisible(); await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible(); await expect(card.getByText("수선 확인", { exact: true }).first()).toBeVisible(); expect(getCount).toBeGreaterThanOrEqual(2); expect(submissions).toHaveLength(1); expect(submissions[0].supersedes_observation_id).toBe(OBSERVATION_TWO_ID); expect(submissions[0].evidence_turn_ids).toEqual([TURN_UUIDS[2], TURN_UUIDS[3]]); await card.locator(".rr-correction").screenshot({ path: testInfo.outputPath(`rupture-correction-${testInfo.project.name}.png`), animations: "disabled", }); await expectNoHorizontalOverflow(page); }); test("정정 오류 재시도는 동일 idempotency UUID와 본문을 보존한다", async ({ page }) => { await prepareReview(page, "teacher"); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) => fulfillJson(route, readModel("teacher")), ); const submissions: Array> = []; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`, async (route) => { submissions.push(route.request().postDataJSON() as Record); if (submissions.length === 1) { await fulfillJson(route, { detail: "temporary correction failure" }, 503); return; } await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201); }, ); const card = await openFeedback(page, "teacher"); await card.getByText("사람 판정으로 정정", { exact: true }).click(); const form = card.locator(".rr-correction form"); await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다."); await form.getByRole("button", { name: "정정 기록 추가" }).click(); await expect(form.getByRole("alert")).toContainText("API 503"); await form.getByRole("button", { name: "같은 요청 다시 제출" }).click(); await expect(form.getByText(/최신 원장을 다시 불러왔습니다/)).toBeVisible(); expect(submissions).toHaveLength(2); expect(submissions[1]).toEqual(submissions[0]); expect(submissions[1].idempotency_key).toBe(submissions[0].idempotency_key); }); });