import { expect, test, type Page, type Route } from "@playwright/test"; import type { AxisTrajectoryAssessment, OutcomeAxis, OutcomeObservationSubmissionResponse, OutcomeTrajectoryResponse, SyntheticExpectedDistribution, TrajectoryStatus, } from "../src/pages/session-review/outcomeTrajectoryApi"; import { FILLED_REVIEW_SESSION_ID, filledReviewResponse, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type ReviewRole = "learner" | "teacher"; const AXES: OutcomeAxis[] = [ "distress_load", "daily_functioning", "learning_engagement", ]; const EXPECTED: Record> = { 1: { distress_load: 0.7, daily_functioning: 0.3, learning_engagement: 0.4 }, 2: { distress_load: 0.62, daily_functioning: 0.4, learning_engagement: 0.48 }, 3: { distress_load: 0.54, daily_functioning: 0.5, learning_engagement: 0.56 }, 4: { distress_load: 0.46, daily_functioning: 0.6, learning_engagement: 0.64 }, 5: { distress_load: 0.38, daily_functioning: 0.68, learning_engagement: 0.72 }, }; const OBSERVED: Record> = { 1: { distress_load: 0.71, daily_functioning: 0.31, learning_engagement: 0.42 }, 2: { distress_load: 0.73, daily_functioning: 0.34, learning_engagement: 0.41 }, 3: { distress_load: 0.67, daily_functioning: 0.38, learning_engagement: null }, 4: { distress_load: 0.82, daily_functioning: 0.29, learning_engagement: 0.3 }, }; const TURN_UUIDS = [ "10000000-0000-4000-8000-000000000001", "10000000-0000-4000-8000-000000000002", "10000000-0000-4000-8000-000000000003", "10000000-0000-4000-8000-000000000004", "10000000-0000-4000-8000-000000000005", "10000000-0000-4000-8000-000000000006", ]; function distributions(): SyntheticExpectedDistribution[] { return [1, 2, 3, 4, 5].flatMap((sessionNo) => AXES.map((axis) => { const mean = EXPECTED[sessionNo][axis]; return { session_no: sessionNo, axis, mean, standard_deviation: 0.1, lower_reference: Math.max(0, mean - 0.2), upper_reference: Math.min(1, mean + 0.2), sample_size: 200, expected_direction: axis === "distress_load" ? "lower_is_better" : "higher_is_better", }; }), ); } function axisAssessment( sessionNo: number, axis: OutcomeAxis, sessionStatus: TrajectoryStatus, ): AxisTrajectoryAssessment { const observed = OBSERVED[sessionNo][axis]; const missing = observed == null; return { session_no: sessionNo, axis, status: missing ? "insufficient_evidence" : sessionStatus, observed_value: observed, expected_mean: EXPECTED[sessionNo][axis], adverse_z: missing ? null : sessionNo === 4 ? 2.1 : 0.7, adverse_z_change: missing ? null : sessionNo === 4 ? 0.8 : 0.2, uncertainty: missing ? 1 : sessionNo === 4 ? 0.24 : 0.36, decision_basis: missing ? ["학습 참여 관측이 저장되지 않아 예상선과 비교하지 않았습니다."] : [ sessionNo === 4 ? "직전 회기보다 불리한 방향의 변화가 두 회기 연속 관찰됐습니다." : "교육용 예상 범위와 관측 근거를 축별로 비교했습니다.", ], counterevidence: sessionNo === 4 && axis === "distress_load" ? ["회기 말에는 감정을 언어로 표현한 장면도 확인됐습니다."] : [], evidence_refs: missing ? [] : [axis === "distress_load" ? "t3" : "t6"], }; } function learnerCheckinObservations(tag: string) { const values = { distress_load: 0.75, daily_functioning: 0.5, learning_engagement: 0.5, } satisfies Record; return AXES.map((axis) => ({ measurement_id: `learner-${tag}-${axis}`, session_id: FILLED_REVIEW_SESSION_ID, session_no: 4, axis, status: "observed" as const, value: values[axis], raw_value: values[axis], scale_min: 0, scale_max: 1, confidence: 0.66, source_kind: "learner_reported" as const, perspective: "learner_self_report" as const, instrument_id: "outcome-learner-checkin", instrument_version: "1.0.0", model_run_id: null, evidence_refs: ["t3"], missing_reason: null, occurred_at: "2026-08-06T09:20:00Z", })); } function trajectoryResponse(): OutcomeTrajectoryResponse { const sessionStatuses: Record = { 1: "on_track", 2: "watch", 3: "insufficient_evidence", 4: "deteriorating", }; return { session_id: FILLED_REVIEW_SESSION_ID, revision_id: "00000000-0000-0000-0000-000000000404", revision_no: 4, supersedes_revision_id: "00000000-0000-0000-0000-000000000303", source_fingerprint: "sha256:e2e-outcome-trajectory-revision-4", recompute_reason: "session_completed", computed_at: "2026-08-06T09:30:00Z", notice_ko: "실제 치료 효과, 임상 규준, 진단 또는 예후를 뜻하지 않는 교육용 합성 비교선입니다.", expected_arc: { schema_version: "vignette.synthetic-outcome-arc.v1", arc_id: "oas-g2-arc-001", title_ko: "교육용 초기 5회기 기대 궤적", data_classification: "synthetic_educational", clinical_claim_allowed: false, provenance_note: "교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않습니다.", session_count: 5, distributions: distributions(), }, assessment: { schema_version: "vignette.outcome-trajectory-assessment.v1", expected_arc_id: "oas-g2-arc-001", data_classification: "synthetic_educational", clinical_claim_allowed: false, sessions: [1, 2, 3, 4].map((sessionNo) => ({ session_no: sessionNo, status: sessionStatuses[sessionNo], axes: AXES.map((axis) => axisAssessment(sessionNo, axis, sessionStatuses[sessionNo])), missing_axes: sessionNo === 3 ? ["learning_engagement"] : [], next_check_questions: sessionNo === 4 ? [ "고통 부담이 커진 구체 장면을 먼저 확인했나요?", "일상 기능의 변화를 내담자의 말로 다시 확인했나요?", ] : ["다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?"], safety_signals: sessionNo === 2 ? [safetySignal()] : [], })), }, observations: [1, 2, 3, 4] .flatMap((sessionNo) => AXES.map((axis) => { const value = OBSERVED[sessionNo][axis]; return { measurement_id: `measurement-${sessionNo}-${axis}`, session_id: FILLED_REVIEW_SESSION_ID, session_no: sessionNo, axis, status: value == null ? "missing" : "observed", value, raw_value: value, scale_min: 0, scale_max: 1, confidence: value == null ? null : 0.84, source_kind: "simulated_state", perspective: "client_simulation", instrument_id: "g2-synthetic-session-outcome", instrument_version: "1.0.0", model_run_id: null, evidence_refs: value == null ? [] : [axis === "distress_load" ? "t3" : "t6"], missing_reason: value == null ? "회기 종료 전 학습 참여 확인 응답이 저장되지 않았습니다." : null, occurred_at: `2026-08-0${sessionNo}T09:00:00Z`, }; }), ) .concat(learnerCheckinObservations("existing")), safety_signals: [safetySignal()], relationship_memory: [ { event_id: "relationship-goal-1", session_no: 1, event_type: "goal_agreement", summary: "비교 경험에서 올라오는 감정을 먼저 살피기로 합의했습니다.", evidence_refs: ["t2"], resolved_by_event_id: null, }, { event_id: "relationship-rupture-2", session_no: 2, event_type: "unresolved_rupture", summary: "행동 연습의 속도가 빠르게 느껴졌는지 다음 회기에 다시 확인할 필요가 있습니다.", evidence_refs: ["t5"], resolved_by_event_id: "relationship-repair-3", }, { event_id: "relationship-repair-3", session_no: 3, event_type: "repair_confirmed", summary: "부담을 다시 확인하고 내담자가 선택한 작은 연습으로 조정했습니다.", evidence_refs: ["t6"], resolved_by_event_id: null, }, { event_id: "relationship-task-4", session_no: 4, event_type: "task_agreement", summary: "다음 회기에는 일상 기능의 변화를 먼저 확인하기로 합의했습니다.", evidence_refs: ["t6"], resolved_by_event_id: null, }, ], next_questions: [ "고통 부담이 커진 구체 장면을 먼저 확인했나요?", "일상 기능의 변화를 내담자의 말로 다시 확인했나요?", "다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?", ], }; } function submittedTrajectoryResponse( submissionId: string, ): OutcomeObservationSubmissionResponse { const current = trajectoryResponse(); const submittedMeasurementIds = AXES.map((axis) => `learner-new-${axis}`); return { ...current, revision_id: "00000000-0000-0000-0000-000000000505", revision_no: 5, supersedes_revision_id: current.revision_id, source_fingerprint: "sha256:e2e-outcome-trajectory-revision-5", recompute_reason: "learner_outcome_observation", computed_at: "2026-08-06T09:35:00Z", observations: [ ...current.observations, ...learnerCheckinObservations("new").map((observation, index) => ({ ...observation, measurement_id: submittedMeasurementIds[index], confidence: 1, occurred_at: "2026-08-06T09:35:00Z", })), ], assessment: { ...current.assessment, sessions: current.assessment.sessions.map((session) => session.session_no === 4 ? { ...session, status: "watch", axes: session.axes.map((axis) => ({ ...axis, status: "watch" })), } : session, ), }, submission_id: submissionId, submitted_measurement_ids: submittedMeasurementIds, }; } function safetySignal() { return { safety_event_id: "safety-ledger-2026-08-06-01", session_no: 2, risk_level: "high" as const, escalated: true, evidence_refs: ["t3"], }; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } 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 routeTrajectory(page: Page, handler: (route: Route) => Promise) { await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`, handler, ); } async function routeOutcomeSubmission( page: Page, handler: (route: Route) => Promise, ) { await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-observations`, handler, ); } async function completeLearnerCheckin(page: Page) { const checkin = page.locator(".ot-checkin"); const axes = checkin.locator(".ot-checkin-axis"); await axes.nth(0).locator(".ot-checkin-scale--score label").nth(3).click(); await axes.nth(1).locator(".ot-checkin-scale--score label").nth(2).click(); await axes.nth(2).locator(".ot-checkin-scale--score label").nth(1).click(); await axes.nth(0).locator(".ot-checkin-scale--confidence label").nth(2).click(); await axes.nth(1).locator(".ot-checkin-scale--confidence label").nth(1).click(); await axes.nth(2).locator(".ot-checkin-scale--confidence label").nth(0).click(); return checkin; } test.describe("G2 종단 성과 궤적", () => { test.beforeEach(async ({ page }) => { await routeReviewUser(page, "learner"); await routeReviewPage(page, "learner"); }); test("축별 궤적, 누락, 안전, 관계 기억과 자기주도 질문을 독립적으로 보여준다", async ({ page, }, testInfo) => { await routeTrajectory(page, async (route) => { await new Promise((resolve) => setTimeout(resolve, 1_500)); await fulfillJson(route, trajectoryResponse()); }); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); await expect(page.getByLabel("종단 성과 궤적 불러오는 중")).toBeVisible(); const card = page.locator(".ot-card"); await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible(); await expect(card.getByText("synthetic_educational", { exact: true })).toBeVisible(); await expect(card.getByText("clinical_claim_allowed: false", { exact: true })).toBeVisible(); await expect(card.getByText(/총점/)).toHaveCount(0); await expect(card.getByRole("tab", { name: /4회기 악화 신호/ })).toHaveAttribute( "aria-selected", "true", ); await expect(card.getByRole("heading", { name: "고통 부담", exact: true })).toBeVisible(); await expect(card.getByRole("heading", { name: "일상 기능", exact: true })).toBeVisible(); await expect(card.getByRole("heading", { name: "학습 참여", exact: true })).toBeVisible(); await expect(card.getByRole("heading", { name: "안전 신호" })).toBeVisible(); await expect(card.getByText("성과 궤적과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible(); await expect(card.getByText("미해결 균열", { exact: true })).toBeVisible(); await expect(card.getByText("복구 확인", { exact: true })).toBeVisible(); await expect(card.getByRole("heading", { name: "4회기 학습자 체크인" })).toBeVisible(); await expect(card.getByText("고통 부담은 높을수록 현재 부담이 큽니다.")).toBeVisible(); await expect(card.getByText(/이전 체크인은 수정되지 않습니다/)).toBeVisible(); await card.locator(".ot-checkin-evidence summary").click(); const firstQuestion = card.getByLabel("고통 부담이 커진 구체 장면을 먼저 확인했나요?"); await firstQuestion.check(); await expect(firstQuestion).toBeChecked(); const fourthTab = card.getByRole("tab", { name: /4회기 악화 신호/ }); await fourthTab.focus(); await page.keyboard.press("Home"); await expect(card.getByRole("tab", { name: /1회기 예상 범위/ })).toHaveAttribute( "aria-selected", "true", ); await page.keyboard.press("End"); await expect(card.getByRole("tab", { name: /5회기 자료 대기/ })).toHaveAttribute( "aria-selected", "true", ); await card.getByRole("tab", { name: /3회기 근거 부족/ }).click(); await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0); await expect(card.getByText("체크인은 최신 회기에 새 기록으로 추가합니다")).toBeVisible(); const learningAxis = card.locator(".ot-axis", { hasText: "학습 참여" }); await expect(learningAxis.getByText("관측 없음", { exact: true }).first()).toBeVisible(); await expect(learningAxis.getByText(/누락 사유: 회기 종료 전/)).toBeVisible(); await learningAxis.getByText("판정 근거와 반대 근거 보기").click(); await expect(learningAxis.getByText(/예상선과 비교하지 않았습니다/)).toBeVisible(); await card.getByRole("tab", { name: /4회기 악화 신호/ }).click(); await card.locator(".ot-checkin-evidence summary").click(); await card.locator(".ot-contract").screenshot({ path: testInfo.outputPath(`outcome-trajectory-contract-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-timeline-wrap").screenshot({ path: testInfo.outputPath(`outcome-trajectory-timeline-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-checkin").screenshot({ path: testInfo.outputPath(`outcome-trajectory-checkin-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-checkin-axis").last().screenshot({ path: testInfo.outputPath(`outcome-trajectory-checkin-axis-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-checkin-evidence").screenshot({ path: testInfo.outputPath(`outcome-trajectory-checkin-evidence-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-checkin__actions").screenshot({ path: testInfo.outputPath(`outcome-trajectory-checkin-actions-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-session-panel").screenshot({ path: testInfo.outputPath(`outcome-trajectory-axes-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-support-grid").screenshot({ path: testInfo.outputPath(`outcome-trajectory-support-${testInfo.project.name}.png`), animations: "disabled", }); await card.locator(".ot-relationship").screenshot({ path: testInfo.outputPath(`outcome-trajectory-relationship-${testInfo.project.name}.png`), animations: "disabled", }); await expectNoHorizontalOverflow(page); }); test("세 축과 확신도를 독립 제출하고 성공 응답으로 즉시 갱신한다", async ({ page, }) => { await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse())); const submissions: Array> = []; await routeOutcomeSubmission(page, async (route) => { const body = route.request().postDataJSON() as Record; submissions.push(body); await new Promise((resolve) => setTimeout(resolve, 280)); await fulfillJson( route, submittedTrajectoryResponse(String(body.submission_id)), 201, ); }); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const checkin = await completeLearnerCheckin(page); await checkin.getByText("축어록 장면 선택 (선택)").click(); await checkin.getByRole("checkbox").first().check(); const submit = checkin.locator('button[type="submit"]'); await submit.click(); await expect(submit).toBeDisabled(); await expect(submit).toHaveText("체크인 기록 중"); await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible(); expect(submissions).toHaveLength(1); expect(submissions[0]).toMatchObject({ scores: { distress_load: 0.75, daily_functioning: 0.5, learning_engagement: 0.25, }, confidences: { distress_load: 1, daily_functioning: 0.66, learning_engagement: 0.33, }, evidence_turn_ids: [TURN_UUIDS[0]], }); expect(String(submissions[0].submission_id)).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); await expect( page.locator(".ot-card").getByRole("tab", { name: /4회기 다음 회기 확인/ }), ).toHaveAttribute("aria-selected", "true"); await expectNoHorizontalOverflow(page); }); test("제출 오류 재시도는 같은 submission_id를 보존한다", async ({ page }) => { await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse())); const submissions: Array> = []; await routeOutcomeSubmission(page, async (route) => { const body = route.request().postDataJSON() as Record; submissions.push(body); if (submissions.length === 1) { await fulfillJson(route, { detail: "temporary learner check-in failure" }, 503); return; } await fulfillJson( route, submittedTrajectoryResponse(String(body.submission_id)), 201, ); }); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const checkin = await completeLearnerCheckin(page); await checkin.getByRole("button", { name: "세 축 체크인 추가" }).click(); await expect(checkin.getByRole("alert")).toContainText("API 503"); await checkin.getByRole("button", { name: "같은 요청 다시 제출" }).click(); await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible(); expect(submissions).toHaveLength(2); expect(submissions[1].submission_id).toBe(submissions[0].submission_id); expect(submissions[1]).toEqual(submissions[0]); }); test("404는 오류 대신 관측 대기 빈 상태로 안내한다", async ({ page }) => { await routeTrajectory(page, (route) => fulfillJson(route, { detail: "outcome trajectory not found" }, 404), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ot-card"); await expect(card.getByRole("heading", { name: "아직 이어 볼 회기 자료가 없습니다" })).toBeVisible(); await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0); }); test("일시적 오류에서 같은 카드 안에서 다시 불러온다", async ({ page }) => { let attempts = 0; let allowSuccess = false; await routeTrajectory(page, async (route) => { attempts += 1; if (!allowSuccess) { await fulfillJson(route, { detail: "trajectory temporarily unavailable" }, 503); return; } await fulfillJson(route, trajectoryResponse()); }); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ot-card"); await expect(card.getByRole("heading", { name: "궤적 자료를 불러오지 못했습니다" })).toBeVisible(); allowSuccess = true; await card.getByRole("button", { name: "다시 불러오기" }).click(); await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible(); expect(attempts).toBeGreaterThanOrEqual(2); }); test("교수자 보기에는 역할 허용 관계 요약만 표시한다", async ({ page }) => { await page.unroute("**/api/auth/me"); await page.unroute(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`); await routeReviewUser(page, "teacher"); await routeReviewPage(page, "teacher"); await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse())); await page.goto(`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ot-card"); await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible(); await expect(card.getByText("교수자 역할에 허용된 요약만 시간순으로 표시합니다.")).toBeVisible(); await expect(card.getByText(/내담자 내부 상태/)).toHaveCount(0); await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0); await expectNoHorizontalOverflow(page); }); });